Files
project.linkedcorp.net/frontend/src/utils/drafts.js
T
Milton Gonzalez e65111a7f4 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>
2026-10-23 11:36:02 -05:00

150 lines
6.4 KiB
JavaScript

// 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; }