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:
@@ -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]);
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user