feat: version 3.0.0 -- Correcciones de auditoria de seguridad, accesibilidad y redundancia

Permisos (superadmin, viewer, busqueda, tareas recurrentes), bootstrap atomico,
revocacion de JWT por version de sesion (028), control optimista de edicion (029),
logo sin SVG + CSP, ingesta endurecida, correos escapados, borradores, paginacion
de proyectos, accesibilidad de dialogos/controles e i18n.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Milton Gonzalez
2026-10-23 11:36:02 -05:00
parent 8ed1d63505
commit e65111a7f4
112 changed files with 2285 additions and 651 deletions
+71 -13
View File
@@ -1,42 +1,100 @@
import { createContext, useContext, useEffect, useMemo, useState, useCallback } from '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);
userRef.current = user;
const logout = useCallback(() => {
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);
api.setToken(null);
setUser(null);
}, []);
useEffect(() => {
setUnauthorizedHandler(() => logout());
// 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(() => {
if (!localStorage.getItem('plc_token')) return;
if (userRef.current) saveReturnContext(userRef.current.id);
markSessionExpired();
logout({ preserveDrafts: true });
}, [logout]);
useEffect(() => {
setUnauthorizedHandler(() => expireSession());
}, [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;
}
api.get('/auth/me')
.then((me) => setUser(me))
.catch(() => logout())
.finally(() => setReady(true));
}, [logout]);
const login = useCallback(async (email, password) => {
const result = await api.post('/auth/login', { email, password });
api.setToken(result.token);
setUser(result.user);
return result.user;
// 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();
};
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);
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) => {
api.setToken(token);
setUser(sessionUser);
}, []);
const setSession = useCallback((token, sessionUser) => { startSession(token, sessionUser); }, [startSession]);
const value = useMemo(() => ({ user, ready, login, logout, setSession }), [user, ready, login, logout, setSession]);