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:
@@ -0,0 +1,149 @@
|
||||
// Borradores de formularios: valores aun no guardados, persistidos en el navegador para no
|
||||
// perderlos al cambiar de pestaña/menu, al recargar (F5) o al vencer la sesion.
|
||||
//
|
||||
// - Clave = usuario + formulario + entidad -> nunca se mezclan entre usuarios ni registros.
|
||||
// - Nunca se guardan secretos: los campos que parecen contraseña/token/PIN se descartan siempre,
|
||||
// ademas de los que cada formulario declare en `exclude`.
|
||||
// - Son SOLO locales: nunca se mandan al backend como dato definitivo; guardar sigue siendo el
|
||||
// boton Guardar de cada formulario.
|
||||
// - `base` = huella de los valores del servidor desde los que se empezo a editar; si al volver la
|
||||
// huella actual difiere, el registro cambio en el servidor (conflicto) y se avisa.
|
||||
|
||||
const PREFIX = 'plc_draft:v1:';
|
||||
const TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
const SECRET_FIELD = /pass(word|wd)?|token|secret|^pin$|pin$/i;
|
||||
const DRAFT_FIELDS = {
|
||||
'project-new': ['projectTypeId', 'priorityId', 'statusId', 'requestingDepartmentId', 'responsibleDepartmentId', 'startDate', 'estimatedEndDate', 'riskLevel'],
|
||||
'project-info': ['editVersion', 'projectTypeId', 'priorityId', 'statusId', 'requestingDepartmentId', 'responsibleDepartmentId', 'startDate', 'estimatedEndDate', 'riskLevel'],
|
||||
'activity-new': ['responsibleUserId', 'priorityId', 'statusId', 'startDate', 'dueDate', 'isMilestone', 'estimatedHours'],
|
||||
'activity-info': ['editVersion', 'responsibleUserId', 'priorityId', 'statusId', 'startDate', 'dueDate', 'isMilestone', 'estimatedHours', 'progressPct'],
|
||||
'task-new': [], 'task-edit': ['editVersion'],
|
||||
'department-new': ['parentId'], 'department-edit': ['editVersion', 'parentId'],
|
||||
'user-new': ['roleId', 'departmentId'],
|
||||
'user-edit': ['editVersion', 'roleId', 'departmentId'],
|
||||
'user-approve': ['editVersion', 'roleId', 'departmentId'],
|
||||
'recurring-new': ['projectId', 'responsibleUserId', 'intervalUnit', 'intervalValue', 'nextDueDate', 'endDate', 'reminderDaysBefore', 'reminderHour'],
|
||||
'recurring-edit': ['editVersion', 'projectId', 'responsibleUserId', 'intervalUnit', 'intervalValue', 'nextDueDate', 'endDate', 'reminderDaysBefore', 'reminderHour'],
|
||||
'device-edit': ['editVersion', 'status', 'assignedUserId', 'departmentId'],
|
||||
'note-edit': ['editVersion'],
|
||||
'comment-edit': ['editVersion'],
|
||||
'smtp-config': ['editVersion'],
|
||||
'project-member-add': ['userId', 'roleInProject'],
|
||||
'activity-convert': [],
|
||||
'task-promote': ['projectId', 'responsibleUserId', 'intervalUnit', 'intervalValue', 'nextDueDate', 'endDate', 'reminderDaysBefore', 'reminderHour'],
|
||||
'company-settings': ['editVersion'],
|
||||
};
|
||||
export function draftAllowedFields(formId) {
|
||||
if (formId?.startsWith('catalog-')) return ['editVersion', 'color', 'level', 'sortOrder', 'isClosed'];
|
||||
return DRAFT_FIELDS[formId] || [];
|
||||
}
|
||||
|
||||
const listeners = new Set();
|
||||
function emit() { listeners.forEach((fn) => fn()); }
|
||||
export function subscribeDrafts(fn) {
|
||||
listeners.add(fn);
|
||||
return () => listeners.delete(fn);
|
||||
}
|
||||
// Otra pestaña del mismo navegador tambien puede crear/borrar borradores.
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('storage', (e) => { if (!e.key || e.key.startsWith(PREFIX)) emit(); });
|
||||
}
|
||||
|
||||
export function draftKey(userId, formId, entityId) {
|
||||
return `${PREFIX}${userId}:${formId}:${entityId ?? 'new'}`;
|
||||
}
|
||||
|
||||
export function sanitizeDraftValues(values, exclude = [], allowed = Object.keys(values || {})) {
|
||||
const out = {};
|
||||
for (const [k, v] of Object.entries(values || {})) {
|
||||
if (!allowed.includes(k) || exclude.includes(k) || SECRET_FIELD.test(k)) continue;
|
||||
if (v instanceof File || v instanceof Blob) continue;
|
||||
out[k] = v;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Huella estable (orden de claves fijo) de los valores sanitizados.
|
||||
export function fingerprint(values, exclude = [], allowed) {
|
||||
const clean = sanitizeDraftValues(values, exclude, allowed);
|
||||
return JSON.stringify(Object.keys(clean).sort().map((k) => [k, clean[k] ?? '']));
|
||||
}
|
||||
|
||||
export function readDraft(key) {
|
||||
try {
|
||||
const raw = localStorage.getItem(key);
|
||||
if (!raw) return null;
|
||||
const draft = JSON.parse(raw);
|
||||
if (!draft?.savedAt || Date.now() - draft.savedAt > TTL_MS) {
|
||||
localStorage.removeItem(key);
|
||||
return null;
|
||||
}
|
||||
const formId = key.slice(PREFIX.length).split(':')[1];
|
||||
const clean = sanitizeDraftValues(draft.values, [], draftAllowedFields(formId));
|
||||
if (JSON.stringify(clean) !== JSON.stringify(draft.values)) {
|
||||
draft.values = clean;
|
||||
localStorage.setItem(key, JSON.stringify(draft));
|
||||
}
|
||||
return draft;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeDraft(key, draft) {
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify({ ...draft, savedAt: Date.now() }));
|
||||
emit();
|
||||
} catch { /* storage lleno o bloqueado: el formulario sigue funcionando sin borrador */ }
|
||||
}
|
||||
|
||||
export function removeDraft(key) {
|
||||
try {
|
||||
if (localStorage.getItem(key) === null) return;
|
||||
localStorage.removeItem(key);
|
||||
emit();
|
||||
} catch { /* nada que limpiar */ }
|
||||
}
|
||||
|
||||
export function clearUserDrafts(userId) {
|
||||
if (!userId) return;
|
||||
const prefix = `${PREFIX}${userId}:`;
|
||||
try {
|
||||
for (const key of Object.keys(localStorage)) if (key.startsWith(prefix)) localStorage.removeItem(key);
|
||||
emit();
|
||||
} catch { /* almacenamiento bloqueado */ }
|
||||
}
|
||||
|
||||
// Todos los borradores vigentes del usuario: [{ key, formId, entityId, ...draft }].
|
||||
export function listDrafts(userId) {
|
||||
if (!userId) return [];
|
||||
const prefix = `${PREFIX}${userId}:`;
|
||||
const out = [];
|
||||
try {
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i);
|
||||
if (!key?.startsWith(prefix)) continue;
|
||||
const draft = readDraft(key);
|
||||
if (!draft) continue;
|
||||
const [formId, entityId] = key.slice(prefix.length).split(':');
|
||||
out.push({ key, formId, entityId: entityId === 'new' ? null : entityId, ...draft });
|
||||
}
|
||||
} catch { /* sin storage */ }
|
||||
return out;
|
||||
}
|
||||
|
||||
// --- Editores abiertos con cambios (para el aviso al salir de una seccion) ---
|
||||
// key -> { label, leavesSection(nextLocation) }
|
||||
const activeEditors = new Map();
|
||||
export function setActiveEditor(key, editor) {
|
||||
if (editor) activeEditors.set(key, editor); else activeEditors.delete(key);
|
||||
}
|
||||
export function getActiveEditors() {
|
||||
return [...activeEditors.entries()].map(([key, e]) => ({ key, ...e }));
|
||||
}
|
||||
|
||||
// Mientras la sesion se cierra/vence, la navegacion a /login no debe pedir confirmacion: los
|
||||
// borradores ya quedaron guardados.
|
||||
let guardBypass = false;
|
||||
export function setGuardBypass(value) { guardBypass = value; }
|
||||
export function isGuardBypassed() { return guardBypass; }
|
||||
Reference in New Issue
Block a user