a6cc41666e
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
117 lines
4.9 KiB
React
117 lines
4.9 KiB
React
import { createContext, useContext, useEffect, useMemo, useState, useCallback, useRef } from 'react';
|
|
import { api, setUnauthorizedHandler } from '../services/api.js';
|
|
import { tokenExpiresAt, isTokenExpired, tokenSubject } from '../utils/jwt.js';
|
|
import { saveReturnContext, markSessionExpired, consumeReturnContext, clearSessionExpiredFlag } from '../utils/session.js';
|
|
import { setGuardBypass, clearUserDrafts } from '../utils/drafts.js';
|
|
|
|
const AuthContext = createContext(null);
|
|
const CHECK_EVERY_MS = 30 * 1000;
|
|
const MAX_TIMEOUT_MS = 2 ** 31 - 1;
|
|
|
|
export function AuthProvider({ children }) {
|
|
const [user, setUser] = useState(null);
|
|
const [ready, setReady] = useState(false);
|
|
const userRef = useRef(null);
|
|
const lastPingRef = useRef(0);
|
|
userRef.current = user;
|
|
|
|
const logout = useCallback((options = {}) => {
|
|
// Salida voluntaria o sesion invalida: la navegacion a /login no debe pedir confirmacion
|
|
// (los borradores ya estan guardados en el navegador).
|
|
setGuardBypass(true);
|
|
if (!options.preserveDrafts) clearUserDrafts(userRef.current?.id);
|
|
if (options.remote) api.post('/auth/logout').catch(() => {});
|
|
api.setToken(null);
|
|
setUser(null);
|
|
}, []);
|
|
|
|
// Vencimiento: recuerda donde estaba el usuario (para volver ahi tras el login) y marca el
|
|
// aviso que muestra la pantalla de login. Idempotente.
|
|
const expireSession = useCallback((info = {}) => {
|
|
if (!localStorage.getItem('plc_token')) return;
|
|
saveReturnContext(userRef.current?.id || tokenSubject(localStorage.getItem('plc_token')));
|
|
markSessionExpired(info.code === 'SESSION_REVOKED');
|
|
logout({ preserveDrafts: true });
|
|
}, [logout]);
|
|
|
|
useEffect(() => {
|
|
setUnauthorizedHandler((info) => expireSession(info));
|
|
}, [expireSession]);
|
|
|
|
useEffect(() => {
|
|
const token = localStorage.getItem('plc_token');
|
|
if (!token) { setReady(true); return; }
|
|
if (isTokenExpired(token)) {
|
|
// Recarga con la sesion ya vencida (ej. pestaña dormida): igual se recuerda donde estaba.
|
|
saveReturnContext(tokenSubject(token));
|
|
markSessionExpired();
|
|
api.setToken(null);
|
|
setReady(true);
|
|
return;
|
|
}
|
|
lastPingRef.current = Date.now();
|
|
api.get('/auth/me')
|
|
.then((me) => setUser(me))
|
|
.catch((error) => { if (error.status !== 401) logout(); })
|
|
.finally(() => setReady(true));
|
|
}, [logout]);
|
|
|
|
// Vigilancia del vencimiento aunque no haya peticiones: temporizador al instante exacto, un
|
|
// chequeo periodico y, sobre todo, al volver a la pestaña/ventana -- el navegador congela los
|
|
// temporizadores de pestañas en segundo plano, asi que no se puede confiar solo en ellos.
|
|
useEffect(() => {
|
|
if (!user) return undefined;
|
|
const check = () => {
|
|
if (isTokenExpired(localStorage.getItem('plc_token'), 0)) { expireSession(); return; }
|
|
if (document.visibilityState === 'visible' && Date.now() - lastPingRef.current >= 60000) {
|
|
lastPingRef.current = Date.now();
|
|
api.get('/auth/me').catch(() => {});
|
|
}
|
|
};
|
|
const exp = tokenExpiresAt(localStorage.getItem('plc_token'));
|
|
const timer = exp ? setTimeout(check, Math.min(Math.max(exp - Date.now(), 0) + 500, MAX_TIMEOUT_MS)) : null;
|
|
const interval = setInterval(check, CHECK_EVERY_MS);
|
|
const onVisible = () => { if (document.visibilityState === 'visible') check(); };
|
|
document.addEventListener('visibilitychange', onVisible);
|
|
window.addEventListener('focus', check);
|
|
window.addEventListener('pageshow', check);
|
|
check();
|
|
return () => {
|
|
if (timer) clearTimeout(timer);
|
|
clearInterval(interval);
|
|
document.removeEventListener('visibilitychange', onVisible);
|
|
window.removeEventListener('focus', check);
|
|
window.removeEventListener('pageshow', check);
|
|
};
|
|
}, [user, expireSession]);
|
|
|
|
// Devuelve la ruta a la que volver tras iniciar sesion (solo si la dejo este mismo usuario).
|
|
const startSession = useCallback((token, sessionUser) => {
|
|
api.setToken(token);
|
|
lastPingRef.current = Date.now();
|
|
clearSessionExpiredFlag();
|
|
setGuardBypass(false);
|
|
setUser(sessionUser);
|
|
return consumeReturnContext(sessionUser.id);
|
|
}, []);
|
|
|
|
const login = useCallback(async (email, password) => {
|
|
const result = await api.post('/auth/login', { email, password });
|
|
const returnTo = startSession(result.token, result.user);
|
|
return { user: result.user, returnTo };
|
|
}, [startSession]);
|
|
|
|
// Usado por RegisterPage cuando el registro devuelve sesion directa (primer usuario = superadmin).
|
|
const setSession = useCallback((token, sessionUser) => { startSession(token, sessionUser); }, [startSession]);
|
|
|
|
const value = useMemo(() => ({ user, ready, login, logout, setSession }), [user, ready, login, logout, setSession]);
|
|
|
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
|
}
|
|
|
|
export function useAuth() {
|
|
const ctx = useContext(AuthContext);
|
|
if (!ctx) throw new Error('useAuth debe usarse dentro de AuthProvider');
|
|
return ctx;
|
|
}
|