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
+1 -1
View File
@@ -33,7 +33,7 @@ export function useAction() {
try {
return await fn();
} catch (err) {
openDialog({ tone: 'error', title: t('common_error'), message: err.message });
openDialog({ tone: err.status === 409 ? 'warning' : 'error', title: t(err.status === 409 ? 'draft_conflict_title' : 'common_error'), message: err.status === 409 ? t('draft_conflict_reload_msg') : err.message });
throw err;
}
}, [openDialog, t]);
+196
View File
@@ -0,0 +1,196 @@
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
import { useAuth } from '../context/auth-context.jsx';
import { useUi } from '../context/ui-context.jsx';
import {
draftKey, readDraft, writeDraft, removeDraft, listDrafts, subscribeDrafts,
sanitizeDraftValues, fingerprint, setActiveEditor, draftAllowedFields,
} from '../utils/drafts.js';
const same = (a, b) => String(a ?? '') === String(b ?? '');
/**
* Estado de un formulario con borrador persistente (ver utils/drafts.js).
*
* - formId: identificador estable del formulario (ej. 'project-info').
* - entityId: id del registro que se edita; null/undefined para "nuevo".
* - initial: valores del servidor (o vacios para "nuevo"); puede ser un objeto nuevo en cada
* render, se compara por huella.
* - label: texto que se muestra en el aviso de cambios pendientes.
* - enabled: false mientras el formulario no esta abierto (no lee ni escribe borrador).
* - exclude: campos que nunca se guardan como borrador (ademas de los secretos por nombre).
* - onDiscard: se llama al descartar (ej. desde el aviso al salir) para cerrar la edicion.
*/
export function useDraftForm({ formId, entityId, initial, label, enabled = true, exclude = [], onDiscard }) {
const { user } = useAuth();
const { t, openDialog } = useUi();
const key = user ? draftKey(user.id, formId, entityId) : null;
const excludeKey = exclude.join(',');
const allowed = draftAllowedFields(formId);
const baseFp = fingerprint(initial, exclude, allowed);
const initialRef = useRef(initial);
initialRef.current = initial;
const onDiscardRef = useRef(onDiscard);
onDiscardRef.current = onDiscard;
const [values, setValuesState] = useState(initial);
const [restored, setRestored] = useState(false);
const [conflict, setConflict] = useState(false);
// Huella del servidor desde la que se empezo a editar (para detectar cambios ajenos al guardar).
const startBaseRef = useRef(baseFp);
// Huella de lo que ya esta guardado como borrador: si no cambia, no se reescribe (asi se
// conserva la URL original donde se estaba editando, aunque se reabra desde otra pestaña).
const persistedFpRef = useRef(null);
// Cargar borrador (o valores del servidor) al abrir el formulario / cambiar de registro.
// Se hace en el render (estado derivado), no en un efecto: asi el efecto que persiste nunca ve
// los valores viejos y no borra por error el borrador recien cargado.
const loadSig = `${key}|${baseFp}|${enabled}`;
const [loadedSig, setLoadedSig] = useState(null);
if (loadedSig !== loadSig) {
setLoadedSig(loadSig);
const draft = enabled && key ? readDraft(key) : null;
persistedFpRef.current = draft ? fingerprint(draft.values, exclude, allowed) : null;
if (draft) {
setValuesState({ ...initial, ...draft.values });
setRestored(true);
startBaseRef.current = draft.base;
setConflict(entityId != null && draft.base !== baseFp);
} else {
setValuesState(initial);
setRestored(false);
setConflict(false);
startBaseRef.current = baseFp;
}
}
const isFieldDirty = useCallback((name) => !same(values?.[name], initial?.[name]), [values, initial]);
const isDirty = useMemo(
() => !!values && Object.keys({ ...values, ...initial }).some((k) => !same(values[k], initial?.[k])),
[values, initial]
);
const discard = useCallback(() => {
// Sincronico: si a continuacion se navega (ej. tras guardar), el aviso no debe saltar.
if (key) { removeDraft(key); setActiveEditor(key, null); }
persistedFpRef.current = null;
setValuesState(initialRef.current);
setRestored(false);
setConflict(false);
onDiscardRef.current?.();
}, [key]);
// Persistir y registrar el editor activo mientras haya cambios.
useEffect(() => {
if (!enabled || !key) return undefined;
const clean = sanitizeDraftValues(values, exclude, allowed);
const fp = fingerprint(clean);
if (fp === persistedFpRef.current) {
// Ya guardado tal cual (ej. recien recuperado): no se reescribe.
} else if (fp !== baseFp) {
persistedFpRef.current = fp;
writeDraft(key, {
values: clean,
base: startBaseRef.current,
label,
path: `${window.location.pathname}${window.location.search}`,
});
} else {
persistedFpRef.current = null;
removeDraft(key);
}
if (isDirty) setActiveEditor(key, { label, discard });
else setActiveEditor(key, null);
return () => setActiveEditor(key, null);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [values, isDirty, enabled, key, baseFp, label, excludeKey]);
const setValues = useCallback((updater) => {
setValuesState((prev) => (typeof updater === 'function' ? updater(prev) : updater));
}, []);
const setField = useCallback((name, value) => setValues((v) => ({ ...v, [name]: value })), [setValues]);
// Tras guardar con exito: el borrador ya no hace falta.
const clear = discard;
const keepMine = useCallback(() => {
startBaseRef.current = baseFp;
setConflict(false);
if (key) {
const draft = readDraft(key);
if (draft) writeDraft(key, { ...draft, base: baseFp });
}
}, [key, baseFp]);
// Envuelve el guardado: si el registro cambio en el servidor desde que se empezo a editar,
// pide confirmacion antes de sobrescribir. fetchCurrent (opcional) trae los valores actuales
// del servidor (mismo formato que `initial`) para detectarlo tambien sin recargar la pagina.
const guardSave = useCallback(async (save, { fetchCurrent } = {}) => {
let changedOnServer = conflict;
if (!changedOnServer && fetchCurrent && entityId != null) {
try {
const current = await fetchCurrent();
changedOnServer = fingerprint(current, exclude, allowed) !== startBaseRef.current;
} catch {
openDialog({ tone: 'error', title: t('common_error'), message: t('draft_conflict_check_failed') });
return undefined;
}
}
if (!changedOnServer) return save();
return new Promise((resolve) => {
openDialog({
tone: 'warning',
title: t('draft_conflict_title'),
message: values?.editVersion ? t('draft_conflict_reload_msg') : t('draft_conflict_save_msg'),
actions: values?.editVersion ? [
{ label: t('common_cancel'), variant: 'standard', onClick: () => resolve(undefined) },
] : [
{ label: t('common_cancel'), variant: 'standard', onClick: () => resolve(undefined) },
{ label: t('draft_conflict_overwrite'), variant: 'danger', onClick: async () => { keepMine(); resolve(await save()); } },
],
});
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [conflict, entityId, excludeKey, openDialog, t, keepMine, values]);
return {
values, setValues, setField, isDirty, isFieldDirty, restored, conflict,
keepMine, discard, clear, guardSave, draftKey: key, label,
};
}
// Lista reactiva de borradores del usuario actual (para marcar pestañas, menus, tarjetas).
export function useUserDrafts() {
const { user } = useAuth();
const snapshotRef = useRef({ fp: '', list: [] });
const getSnapshot = useCallback(() => {
const list = listDrafts(user?.id);
const fp = list.map((d) => `${d.key}@${d.savedAt}`).sort().join('|');
if (fp !== snapshotRef.current.fp) snapshotRef.current = { fp, list };
return snapshotRef.current.list;
}, [user?.id]);
return useSyncExternalStore(subscribeDrafts, getSnapshot, getSnapshot);
}
export function hasDraftFor(drafts, formId, entityId) {
return drafts.some((d) => d.formId === formId && (entityId === undefined || String(d.entityId) === String(entityId ?? null)));
}
// Reabre, una sola vez al entrar a la pantalla, el popup que quedo con borrador:
// - newFormId: borrador de "Nuevo ..." -> openNew()
// - editFormId: borrador de edicion -> openEdit(item), SOLO si el registro esta en `items` (la
// lista que el backend devolvio para este usuario: si ya no existe o perdio el acceso, no se
// restaura nada).
export function useReopenDraft({ newFormId, editFormId, items, openNew, openEdit }) {
const drafts = useUserDrafts();
const done = useRef(false);
useEffect(() => {
if (done.current) return;
if (newFormId && hasDraftFor(drafts, newFormId, null)) { done.current = true; openNew(); return; }
if (!editFormId) { done.current = true; return; }
if (!items) return;
done.current = true;
const d = drafts.find((x) => x.formId === editFormId && items.some((i) => String(i.id) === String(x.entityId)));
if (d) openEdit(items.find((i) => String(i.id) === String(d.entityId)));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [items, drafts]);
}
+35
View File
@@ -0,0 +1,35 @@
import { useEffect, useRef } from 'react';
export function useModalFocus(open, onEscape) {
const ref = useRef(null);
const escapeRef = useRef(onEscape);
escapeRef.current = onEscape;
useEffect(() => {
if (!open) return undefined;
const previous = document.activeElement;
const frame = requestAnimationFrame(() => {
const root = ref.current;
if (root && !root.contains(document.activeElement)) {
(root.querySelector('[autofocus]') || root.querySelector('button, input, textarea, select, [tabindex="0"]') || root).focus();
}
});
const handleKey = (event) => {
const root = ref.current;
if (!root) return;
const dialogs = [...document.querySelectorAll('[aria-modal="true"]')];
if (dialogs.at(-1) !== root) return;
if (event.key === 'Escape' && escapeRef.current) { event.preventDefault(); escapeRef.current(); return; }
if (event.key !== 'Tab') return;
const focusable = [...root.querySelectorAll('button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled]), a[href], [tabindex="0"]')]
.filter((element) => element.getClientRects().length);
if (!focusable.length) { event.preventDefault(); root.focus(); return; }
const first = focusable[0];
const last = focusable.at(-1);
if (event.shiftKey && (document.activeElement === first || !root.contains(document.activeElement))) { event.preventDefault(); last.focus(); }
else if (!event.shiftKey && (document.activeElement === last || !root.contains(document.activeElement))) { event.preventDefault(); first.focus(); }
};
document.addEventListener('keydown', handleKey);
return () => { cancelAnimationFrame(frame); document.removeEventListener('keydown', handleKey); previous?.focus?.(); };
}, [open]);
return ref;
}