Fix Android documents and idle session
This commit is contained in:
+8
-1
@@ -2,10 +2,17 @@
|
||||
|
||||
## Versión actual
|
||||
|
||||
- Versión base: `1.1.1`
|
||||
- Versión base: `1.1.2`
|
||||
|
||||
## Historial
|
||||
|
||||
### 1.1.2 - 2026-04-23
|
||||
|
||||
- Se separa en Android la vista de documentos firmados y originales.
|
||||
- Se deja una sola acción para abrir documentos en Android y se elimina la acción duplicada.
|
||||
- Se renueva la sesión por actividad en web y Android para que expire tras inactividad.
|
||||
- Se refresca el token Android antes de abrir documentos cuando está cerca de vencer.
|
||||
|
||||
### 1.1.1 - 2026-04-23
|
||||
|
||||
- Se oculta la barra de navegación del sistema Android en modo inmersivo.
|
||||
|
||||
@@ -18,8 +18,8 @@ android {
|
||||
applicationId "net.linkedcorp.linkedsign"
|
||||
minSdk 26
|
||||
targetSdk 35
|
||||
versionCode 10101
|
||||
versionName "1.1.1"
|
||||
versionCode 10102
|
||||
versionName "1.1.2"
|
||||
|
||||
buildConfigField "String", "API_BASE_URL", "\"${apiBaseUrl}\""
|
||||
}
|
||||
|
||||
@@ -21,6 +21,8 @@ import java.io.FileInputStream
|
||||
import java.io.IOException
|
||||
import java.util.Locale
|
||||
|
||||
private const val TOKEN_REFRESH_MARGIN_MS = 6 * 60 * 1000L
|
||||
|
||||
class LinkedSignRepository(private val context: Context) {
|
||||
private val sessionStore = SessionStore(context)
|
||||
private val localCache = LocalCache(context)
|
||||
@@ -203,7 +205,16 @@ class LinkedSignRepository(private val context: Context) {
|
||||
result
|
||||
} }
|
||||
|
||||
fun getAccessToken(): String? = sessionStore.load().accessToken
|
||||
suspend fun getFreshAccessToken(): String? {
|
||||
val session = sessionStore.load()
|
||||
if (session.accessToken.isNullOrBlank()) return null
|
||||
|
||||
if ((session.expiresAt ?: Long.MAX_VALUE) > System.currentTimeMillis() + TOKEN_REFRESH_MARGIN_MS) {
|
||||
return session.accessToken
|
||||
}
|
||||
|
||||
return refreshSession(session).accessToken
|
||||
}
|
||||
|
||||
private suspend fun <T> withAuth(block: suspend (String) -> T): T {
|
||||
val session = sessionStore.load()
|
||||
@@ -214,23 +225,28 @@ class LinkedSignRepository(private val context: Context) {
|
||||
} catch (error: HttpException) {
|
||||
if (error.code() != 401) throw mapHttpException(error)
|
||||
|
||||
val refreshToken = session.refreshToken ?: throwSessionExpired()
|
||||
val refresh = try {
|
||||
unwrap(service.refresh(RefreshRequest(refreshToken)))
|
||||
} catch (refreshError: Exception) {
|
||||
sessionStore.clearSession()
|
||||
throwSessionExpired()
|
||||
}
|
||||
val updated = session.copy(
|
||||
accessToken = refresh.accessToken,
|
||||
refreshToken = refresh.refreshToken
|
||||
)
|
||||
sessionStore.save(updated)
|
||||
sessionStore.updateBiometricSession(updated.user?.email ?: session.user?.email.orEmpty(), refresh.refreshToken)
|
||||
block("Bearer ${refresh.accessToken}")
|
||||
val updated = refreshSession(session)
|
||||
block("Bearer ${updated.accessToken}")
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun refreshSession(session: SessionSnapshot): SessionSnapshot {
|
||||
val refreshToken = session.refreshToken ?: throwSessionExpired()
|
||||
val refresh = try {
|
||||
unwrap(service.refresh(RefreshRequest(refreshToken)))
|
||||
} catch (refreshError: Exception) {
|
||||
sessionStore.clearSession()
|
||||
throwSessionExpired()
|
||||
}
|
||||
val updated = session.copy(
|
||||
accessToken = refresh.accessToken,
|
||||
refreshToken = refresh.refreshToken
|
||||
)
|
||||
sessionStore.save(updated)
|
||||
sessionStore.updateBiometricSession(updated.user?.email ?: session.user?.email.orEmpty(), refresh.refreshToken)
|
||||
return updated
|
||||
}
|
||||
|
||||
private fun throwSessionExpired(): Nothing {
|
||||
throw AppException("Tu sesión expiró. Vuelve a iniciar sesión.", "SESSION_EXPIRED")
|
||||
}
|
||||
|
||||
@@ -30,11 +30,6 @@ class SessionStore(context: Context) {
|
||||
val userJson = preferences.getString(KEY_USER, null)
|
||||
val expiresAt = preferences.getLong(KEY_EXPIRES_AT, 0L).takeIf { it > 0L }
|
||||
|
||||
if (expiresAt != null && expiresAt <= System.currentTimeMillis()) {
|
||||
clearSession()
|
||||
return SessionSnapshot()
|
||||
}
|
||||
|
||||
val user = userJson?.let { userAdapter.fromJson(it) }
|
||||
return SessionSnapshot(accessToken, refreshToken, user, expiresAt)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package net.linkedcorp.linkedsign.ui
|
||||
|
||||
import android.app.DownloadManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.Bitmap
|
||||
@@ -9,7 +8,6 @@ import android.hardware.biometrics.BiometricPrompt
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.CancellationSignal
|
||||
import android.os.Environment
|
||||
import android.os.ParcelFileDescriptor
|
||||
import android.widget.Toast
|
||||
import androidx.activity.ComponentActivity
|
||||
@@ -50,7 +48,6 @@ import androidx.compose.material.icons.outlined.Badge
|
||||
import androidx.compose.material.icons.outlined.CheckCircle
|
||||
import androidx.compose.material.icons.outlined.Dashboard
|
||||
import androidx.compose.material.icons.outlined.Description
|
||||
import androidx.compose.material.icons.outlined.Download
|
||||
import androidx.compose.material.icons.outlined.Draw
|
||||
import androidx.compose.material.icons.outlined.Delete
|
||||
import androidx.compose.material.icons.outlined.Fingerprint
|
||||
@@ -120,6 +117,7 @@ import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -224,7 +222,23 @@ fun LinkedSignApp(viewModel: MainViewModel, appContext: Context, activity: Compo
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(state.authenticated) {
|
||||
while (state.authenticated) {
|
||||
delay(60 * 1000L)
|
||||
runCatching { viewModel.keepSessionAliveIfActive() }
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
modifier = Modifier.pointerInput(state.authenticated) {
|
||||
if (!state.authenticated) return@pointerInput
|
||||
awaitPointerEventScope {
|
||||
while (true) {
|
||||
awaitPointerEvent()
|
||||
viewModel.noteUserActivity()
|
||||
}
|
||||
}
|
||||
},
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
containerColor = SurfaceSoft,
|
||||
topBar = {
|
||||
@@ -628,7 +642,7 @@ private fun AppFlow(
|
||||
onDeleteDocument: (UnifiedDocumentItem) -> Unit,
|
||||
onSign: (Int, Int, String, String, String, Int, SignatureRect?) -> Unit,
|
||||
onSelectDocumentToSign: (Int) -> Unit,
|
||||
accessToken: () -> String?
|
||||
accessToken: suspend () -> String?
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
@@ -866,7 +880,7 @@ private fun DashboardScreen(state: MainUiState, onSync: () -> Unit) {
|
||||
@Composable
|
||||
private fun DocumentsScreen(
|
||||
state: MainUiState,
|
||||
accessToken: () -> String?,
|
||||
accessToken: suspend () -> String?,
|
||||
appContext: Context,
|
||||
onSync: () -> Unit,
|
||||
onUploadDocument: (Uri, String) -> Unit,
|
||||
@@ -875,6 +889,7 @@ private fun DocumentsScreen(
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
var deleteTarget by remember { mutableStateOf<UnifiedDocumentItem?>(null) }
|
||||
var showSignedDocuments by rememberSaveable { mutableStateOf(true) }
|
||||
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
|
||||
uri ?: return@rememberLauncherForActivityResult
|
||||
try {
|
||||
@@ -889,24 +904,30 @@ private fun DocumentsScreen(
|
||||
onUploadDocument(uri, name)
|
||||
}
|
||||
|
||||
val signedByDocumentId = state.signedDocuments.associateBy { it.documentId }
|
||||
val unifiedDocuments = state.documents.map { document ->
|
||||
val signed = signedByDocumentId[document.id]
|
||||
val isSignedDocument = signed != null || document.status.equals("signed", ignoreCase = true)
|
||||
val documentsById = state.documents.associateBy { it.id }
|
||||
val signedDocuments = state.signedDocuments.mapNotNull { signed ->
|
||||
val documentId = signed.documentId ?: return@mapNotNull null
|
||||
val original = documentsById[documentId]
|
||||
UnifiedDocumentItem(
|
||||
documentId = documentId,
|
||||
title = original?.title ?: original?.filename ?: signed.documentTitle ?: signed.signedFilename ?: "Documento firmado",
|
||||
subtitle = signed.signedAt ?: "PDF firmado",
|
||||
originalFilename = original?.filename ?: "${original?.title ?: "documento"}.pdf",
|
||||
signedSignatureId = signed.id,
|
||||
signedFilename = signed.signedFilename,
|
||||
isSigned = true
|
||||
)
|
||||
}
|
||||
val originalDocuments = state.documents.map { document ->
|
||||
UnifiedDocumentItem(
|
||||
documentId = document.id,
|
||||
title = document.title ?: document.filename ?: "Documento",
|
||||
subtitle = if (isSignedDocument) {
|
||||
signed?.signedAt ?: "PDF firmado"
|
||||
} else {
|
||||
document.status ?: "PDF original"
|
||||
},
|
||||
subtitle = "PDF original",
|
||||
originalFilename = document.filename ?: "${document.title ?: "documento"}.pdf",
|
||||
signedSignatureId = signed?.id,
|
||||
signedFilename = signed?.signedFilename,
|
||||
isSigned = isSignedDocument
|
||||
isSigned = false
|
||||
)
|
||||
}
|
||||
val visibleDocuments = if (showSignedDocuments) signedDocuments else originalDocuments
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
@@ -918,16 +939,26 @@ private fun DocumentsScreen(
|
||||
FilledActionButton("Subir documento", Icons.Outlined.Article, modifier = Modifier.weight(1f)) {
|
||||
launcher.launch(arrayOf("application/pdf"))
|
||||
}
|
||||
OutlinedActionButton(
|
||||
if (showSignedDocuments) "Originales" else "Firmados",
|
||||
Icons.Outlined.PictureAsPdf,
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
showSignedDocuments = !showSignedDocuments
|
||||
}
|
||||
OutlinedActionButton("Actualizar", Icons.Outlined.Sync, modifier = Modifier.weight(1f), onClick = onSync)
|
||||
}
|
||||
}
|
||||
item {
|
||||
CompactSectionLabel("MIS DOCUMENTOS")
|
||||
SectionCard(title = "Documentos") {
|
||||
if (unifiedDocuments.isEmpty()) {
|
||||
Text("No hay documentos sincronizados.", color = Color(0xFF6B7280))
|
||||
CompactSectionLabel(if (showSignedDocuments) "DOCUMENTOS FIRMADOS" else "DOCUMENTOS ORIGINALES")
|
||||
SectionCard(title = if (showSignedDocuments) "Firmados" else "Originales") {
|
||||
if (visibleDocuments.isEmpty()) {
|
||||
Text(
|
||||
if (showSignedDocuments) "No hay documentos firmados sincronizados." else "No hay documentos originales sincronizados.",
|
||||
color = Color(0xFF6B7280)
|
||||
)
|
||||
} else {
|
||||
unifiedDocuments.forEach { item ->
|
||||
visibleDocuments.forEach { item ->
|
||||
DocumentRow(
|
||||
title = item.title,
|
||||
subtitle = item.subtitle,
|
||||
@@ -955,23 +986,6 @@ private fun DocumentsScreen(
|
||||
}
|
||||
}
|
||||
},
|
||||
onDownload = {
|
||||
if (item.isSigned && item.signedSignatureId != null) {
|
||||
downloadFile(
|
||||
appContext,
|
||||
"${BuildConfig.API_BASE_URL}/signatures/${item.signedSignatureId}/download",
|
||||
accessToken(),
|
||||
item.signedFilename ?: "firmado.pdf"
|
||||
)
|
||||
} else {
|
||||
downloadFile(
|
||||
appContext,
|
||||
"${BuildConfig.API_BASE_URL}/documents/${item.documentId}/download",
|
||||
accessToken(),
|
||||
item.originalFilename
|
||||
)
|
||||
}
|
||||
},
|
||||
onDelete = { deleteTarget = item }
|
||||
)
|
||||
}
|
||||
@@ -1059,7 +1073,7 @@ private fun CertificatesScreen(
|
||||
@Composable
|
||||
private fun SignScreen(
|
||||
state: MainUiState,
|
||||
accessToken: () -> String?,
|
||||
accessToken: suspend () -> String?,
|
||||
appContext: Context,
|
||||
initialDocumentId: String?,
|
||||
onSign: (Int, Int, String, String, String, Int, SignatureRect?) -> Unit
|
||||
@@ -1451,7 +1465,7 @@ private fun SectionCard(title: String, content: @Composable ColumnScope.() -> Un
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DocumentRow(title: String, subtitle: String, onSelect: (() -> Unit)? = null, onView: () -> Unit, onDownload: () -> Unit, onDelete: () -> Unit) {
|
||||
private fun DocumentRow(title: String, subtitle: String, onSelect: (() -> Unit)? = null, onView: () -> Unit, onDelete: () -> Unit) {
|
||||
val isSigned = subtitle.contains("firmad", ignoreCase = true) || subtitle.contains("signed", ignoreCase = true)
|
||||
val compactSubtitle = subtitle
|
||||
.replace("signed", "", ignoreCase = true)
|
||||
@@ -1520,9 +1534,6 @@ private fun DocumentRow(title: String, subtitle: String, onSelect: (() -> Unit)?
|
||||
IconButton(onClick = onView, modifier = Modifier.size(28.dp)) {
|
||||
Icon(Icons.Outlined.Visibility, contentDescription = "Ver documento", tint = Brand, modifier = Modifier.size(17.dp))
|
||||
}
|
||||
IconButton(onClick = onDownload, modifier = Modifier.size(28.dp)) {
|
||||
Icon(Icons.Outlined.Download, contentDescription = "Descargar documento", tint = TextMuted, modifier = Modifier.size(17.dp))
|
||||
}
|
||||
IconButton(onClick = onDelete, modifier = Modifier.size(28.dp)) {
|
||||
Icon(Icons.Outlined.Delete, contentDescription = "Eliminar documento", tint = Danger, modifier = Modifier.size(17.dp))
|
||||
}
|
||||
@@ -1813,21 +1824,6 @@ private fun queryFileName(context: Context, uri: Uri): String? {
|
||||
}
|
||||
}
|
||||
|
||||
private fun downloadFile(context: Context, url: String, accessToken: String?, fileName: String) {
|
||||
val request = DownloadManager.Request(Uri.parse(url))
|
||||
.setTitle(fileName)
|
||||
.setDescription("Descargando archivo de LinkedSign")
|
||||
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
|
||||
.setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, fileName)
|
||||
|
||||
if (!accessToken.isNullOrBlank()) {
|
||||
request.addRequestHeader("Authorization", "Bearer $accessToken")
|
||||
}
|
||||
|
||||
val manager = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
|
||||
manager.enqueue(request)
|
||||
}
|
||||
|
||||
private suspend fun openSecurePdf(context: Context, url: String, accessToken: String?, fileName: String) {
|
||||
val targetFile = downloadSecuredFileToCache(context, url, accessToken, fileName)
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ data class MainUiState(
|
||||
|
||||
class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
private val repository = LinkedSignRepository(application.applicationContext)
|
||||
private var lastActivityAt = System.currentTimeMillis()
|
||||
|
||||
var uiState by mutableStateOf(MainUiState())
|
||||
private set
|
||||
@@ -137,13 +138,29 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
repository.requestPasswordChange(currentPassword, newPassword)
|
||||
}
|
||||
|
||||
fun noteUserActivity() {
|
||||
lastActivityAt = System.currentTimeMillis()
|
||||
}
|
||||
|
||||
suspend fun keepSessionAliveIfActive() {
|
||||
if (!uiState.authenticated) return
|
||||
if (System.currentTimeMillis() - lastActivityAt > IDLE_SESSION_MS) return
|
||||
try {
|
||||
repository.getFreshAccessToken()
|
||||
val (session, cache) = repository.loadLocalSnapshot()
|
||||
uiState = buildState(session, cache, loading = false, syncing = uiState.syncing)
|
||||
} catch (error: Exception) {
|
||||
normalize(error)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun logout() {
|
||||
repository.logout()
|
||||
val (session, cache) = repository.loadLocalSnapshot()
|
||||
uiState = buildState(session, cache, loading = false)
|
||||
}
|
||||
|
||||
fun accessToken(): String? = repository.getAccessToken()
|
||||
suspend fun accessToken(): String? = repository.getFreshAccessToken()
|
||||
|
||||
fun enableBiometricAccess() {
|
||||
repository.enableBiometricAccess()
|
||||
@@ -196,4 +213,8 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
return if (error is AppException) error else AppException(error.message ?: "Ocurrió un error inesperado.")
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val IDLE_SESSION_MS = 15 * 60 * 1000L
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "linkedsign-backend",
|
||||
"version": "1.1.1",
|
||||
"version": "1.1.2",
|
||||
"private": true,
|
||||
"description": "Initial backend for LinkedSign",
|
||||
"main": "src/server.js",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "1.1.1",
|
||||
"version": "1.1.2",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -6,6 +6,7 @@ export const register = (data) => client.post('/auth/register', data, {
|
||||
},
|
||||
});
|
||||
export const login = (data) => client.post('/auth/login', data);
|
||||
export const refreshSession = () => client.post('/auth/refresh', {});
|
||||
export const logout = () => client.post('/auth/logout', {});
|
||||
export const getMe = () => client.get('/auth/me');
|
||||
export const verifyEmail = (token) => client.get(`/auth/verify/${token}`);
|
||||
|
||||
@@ -1,31 +1,24 @@
|
||||
import { createContext, useContext, useState, useEffect, useCallback } from 'react';
|
||||
import { login as apiLogin, logout as apiLogout, getMe } from '../api/auth';
|
||||
import { createContext, useContext, useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { login as apiLogin, logout as apiLogout, getMe, refreshSession as apiRefreshSession } from '../api/auth';
|
||||
import { updateSignatureAppearancePreference as apiUpdateSignatureAppearancePreference } from '../api/users';
|
||||
import {
|
||||
clearAccessToken,
|
||||
expireSession,
|
||||
getAccessToken,
|
||||
getAccessTokenExpiryDelay,
|
||||
SESSION_EXPIRED_EVENT,
|
||||
setAccessToken,
|
||||
} from '../utils/session';
|
||||
|
||||
const AuthContext = createContext(null);
|
||||
const IDLE_SESSION_MS = 15 * 60 * 1000;
|
||||
const ACTIVE_REFRESH_MS = 9 * 60 * 1000;
|
||||
const ACTIVITY_EVENTS = ['click', 'keydown', 'mousemove', 'scroll', 'touchstart'];
|
||||
|
||||
export function AuthProvider({ children }) {
|
||||
const [user, setUser] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const scheduleSessionExpiry = useCallback((token) => {
|
||||
const delay = getAccessTokenExpiryDelay(token);
|
||||
if (delay === null) return undefined;
|
||||
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
expireSession();
|
||||
}, delay);
|
||||
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
}, []);
|
||||
const lastActivityRef = useRef(Date.now());
|
||||
const lastRefreshRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
const token = getAccessToken();
|
||||
@@ -48,15 +41,50 @@ export function AuthProvider({ children }) {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const token = getAccessToken();
|
||||
if (!user || !token) return undefined;
|
||||
return scheduleSessionExpiry(token);
|
||||
}, [scheduleSessionExpiry, user]);
|
||||
if (!user) return undefined;
|
||||
|
||||
const registerActivity = () => {
|
||||
const now = Date.now();
|
||||
if (now - lastActivityRef.current > IDLE_SESSION_MS) {
|
||||
expireSession();
|
||||
return;
|
||||
}
|
||||
lastActivityRef.current = now;
|
||||
};
|
||||
|
||||
const keepAlive = async () => {
|
||||
const now = Date.now();
|
||||
if (now - lastActivityRef.current > IDLE_SESSION_MS) {
|
||||
expireSession();
|
||||
return;
|
||||
}
|
||||
|
||||
if (now - lastRefreshRef.current < ACTIVE_REFRESH_MS || !getAccessToken()) return;
|
||||
|
||||
try {
|
||||
const { data } = await apiRefreshSession();
|
||||
setAccessToken(data.data.accessToken);
|
||||
lastRefreshRef.current = now;
|
||||
} catch {
|
||||
expireSession();
|
||||
}
|
||||
};
|
||||
|
||||
ACTIVITY_EVENTS.forEach((eventName) => window.addEventListener(eventName, registerActivity, { passive: true }));
|
||||
const intervalId = window.setInterval(keepAlive, 60 * 1000);
|
||||
|
||||
return () => {
|
||||
ACTIVITY_EVENTS.forEach((eventName) => window.removeEventListener(eventName, registerActivity));
|
||||
window.clearInterval(intervalId);
|
||||
};
|
||||
}, [user]);
|
||||
|
||||
const login = useCallback(async (email, password) => {
|
||||
const { data } = await apiLogin({ email, password });
|
||||
const { accessToken, user: userData } = data.data;
|
||||
setAccessToken(accessToken);
|
||||
lastActivityRef.current = Date.now();
|
||||
lastRefreshRef.current = Date.now();
|
||||
setUser(userData);
|
||||
return userData;
|
||||
}, []);
|
||||
|
||||
Reference in New Issue
Block a user