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
@@ -8,6 +8,9 @@ import { Button } from '../../components/Button.jsx';
import { Field } from '../../components/Field.jsx';
import { ComboBox } from '../../components/ComboBox.jsx';
import { DetailPopup } from '../../components/DetailPopup.jsx';
import { DraftNotice } from '../../components/DraftNotice.jsx';
import { DraftMark, useGuardedClose } from '../../components/NavigationGuard.jsx';
import { useDraftForm, useUserDrafts, hasDraftFor, useReopenDraft } from '../../hooks/useDraftForm.js';
function flatten(nodes, acc = []) {
for (const n of nodes) {
@@ -23,32 +26,42 @@ export default function DepartmentsPanel() {
const { data: tree, reload } = useAsync(listDepartments, []);
const [selected, setSelected] = useState(null);
const [editing, setEditing] = useState(false);
const [form, setForm] = useState({ name: '', parentId: '' });
const guardedClose = useGuardedClose();
// Borrador del departamento (nuevo o edicion).
const draft = useDraftForm({
formId: selected ? 'department-edit' : 'department-new', entityId: selected?.id ?? null,
initial: selected ? { name: selected.name, parentId: selected.parent_id || '', editVersion: selected.edit_version } : { name: '', parentId: '' },
label: selected ? `${t('common_edit')}: ${selected.name}` : t('department_new'), enabled: editing,
});
const form = draft.values;
const setForm = draft.setValues;
const flatOptions = tree ? flatten(tree).map((d) => ({ value: d.id, label: d.name })) : [];
const pendingNew = hasDraftFor(useUserDrafts(), 'department-new', null);
useReopenDraft({ newFormId: 'department-new', editFormId: 'department-edit', items: tree ? flatten(tree) : null, openNew: () => startNew(), openEdit: (n) => startEdit(n) });
function startNew() {
setSelected(null);
setForm({ name: '', parentId: '' });
setEditing(true);
}
function startEdit(node) {
setSelected(node);
setForm({ name: node.name, parentId: node.parent_id || '' });
setEditing(true);
}
async function save() {
if (!form.name.trim()) return;
await runAction(async () => {
const payload = { name: form.name.trim(), parentId: form.parentId || null };
const run = () => runAction(async () => {
const payload = { name: form.name.trim(), parentId: form.parentId || null, editVersion: form.editVersion };
if (selected) await updateDepartment(selected.id, payload);
else await createDepartment(payload);
draft.clear();
await reload();
setEditing(false);
pushToast({ tone: 'success', message: t('common_saved') });
});
if (selected) await draft.guardSave(run); else await run();
}
function confirmDelete(node) {
@@ -62,6 +75,7 @@ export default function DepartmentsPanel() {
label: t('common_delete'), variant: 'danger',
onClick: () => runAction(async () => {
await deleteDepartment(node.id);
draft.discard();
await reload();
setEditing(false);
pushToast({ tone: 'success', message: t('common_saved') });
@@ -78,16 +92,17 @@ export default function DepartmentsPanel() {
<div className="panel-card">
<div className="toolbar">
<h3 style={{ margin: 0 }}>{t('org_departments')}</h3>
<Button variant="accent" icon={Plus} onClick={startNew}>{t('department_new')}</Button>
<Button variant="accent" icon={Plus} onClick={startNew}>{t('department_new')}{pendingNew && !editing && <DraftMark />}</Button>
</div>
<Treeview nodes={tree} onSelect={startEdit} selectedId={selected?.id} />
</div>
<DetailPopup open={editing} title={selected ? t('common_edit') : t('department_new')} onClose={() => setEditing(false)} width={480} height={360}>
<Field label={t('common_name')}>
<DetailPopup open={editing} title={selected ? t('common_edit') : t('department_new')} onClose={() => guardedClose(draft, () => setEditing(false))} width={480} height={400}>
<DraftNotice draft={draft} />
<Field label={t('common_name')} dirty={draft.isFieldDirty('name')}>
<input className="input" value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} autoFocus />
</Field>
<Field label={t('department_parent')}>
<Field label={t('department_parent')} dirty={draft.isFieldDirty('parentId')}>
<ComboBox
options={[{ value: '', label: '—' }, ...flatOptions.filter((o) => o.value !== selected?.id)]}
value={form.parentId}
@@ -96,7 +111,7 @@ export default function DepartmentsPanel() {
</Field>
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
<Button variant="accent" icon={Save} onClick={save}>{t('common_save')}</Button>
<Button variant="standard" onClick={() => setEditing(false)}>{t('common_cancel')}</Button>
<Button variant="standard" onClick={() => { draft.discard(); setEditing(false); }}>{t('common_cancel')}</Button>
{selected && <Button variant="danger" icon={Trash2} onClick={() => confirmDelete(selected)}>{t('common_delete')}</Button>}
</div>
</DetailPopup>
@@ -1,20 +1,26 @@
import { useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useUi } from '../../context/ui-context.jsx';
import { Tabs } from '../../components/Tabs.jsx';
import { useUserDrafts } from '../../hooks/useDraftForm.js';
import DepartmentsPanel from './DepartmentsPanel.jsx';
import UsersPanel from './UsersPanel.jsx';
export default function OrganizacionPage() {
const { t } = useUi();
const [tab, setTab] = useState('departments');
// Pestaña en la URL (?tab=): retorno tras el login/F5 y aviso al cambiar con cambios pendientes.
const [searchParams, setSearchParams] = useSearchParams();
const tab = searchParams.get('tab') === 'users' ? 'users' : 'departments';
const setTab = (key) => setSearchParams({ tab: key }, { replace: true });
const drafts = useUserDrafts();
const dirty = (prefix) => drafts.some((d) => d.formId.startsWith(prefix));
return (
<div>
<h2 className="page-title mt-0" style={{ marginBottom: 16 }}>{t('nav_organization')}</h2>
<Tabs
tabs={[
{ key: 'departments', label: t('org_departments') },
{ key: 'users', label: t('org_users') },
{ key: 'departments', label: t('org_departments'), dirty: dirty('department-') },
{ key: 'users', label: t('org_users'), dirty: dirty('user-') },
]}
active={tab}
onChange={setTab}
+49 -25
View File
@@ -12,6 +12,9 @@ import { Field, FieldRow } from '../../components/Field.jsx';
import { ComboBox } from '../../components/ComboBox.jsx';
import { Badge } from '../../components/Badge.jsx';
import { DetailPopup } from '../../components/DetailPopup.jsx';
import { DraftNotice } from '../../components/DraftNotice.jsx';
import { DraftMark, useGuardedClose } from '../../components/NavigationGuard.jsx';
import { useDraftForm, useUserDrafts, hasDraftFor, useReopenDraft } from '../../hooks/useDraftForm.js';
import { PasswordInput } from '../../components/PasswordInput.jsx';
function flatten(nodes, acc = []) {
@@ -35,39 +38,59 @@ export default function UsersPanel() {
const [search, setSearch] = useState('');
const [editing, setEditing] = useState(false);
const [selected, setSelected] = useState(null);
const [form, setForm] = useState(EMPTY_FORM);
const [approving, setApproving] = useState(null);
const [approveForm, setApproveForm] = useState({ roleId: '', departmentId: '' });
const guardedClose = useGuardedClose();
// Borrador del usuario (nuevo o edicion). La contraseña nunca se guarda como borrador.
const draft = useDraftForm({
formId: selected ? 'user-edit' : 'user-new', entityId: selected?.id ?? null,
initial: selected
? { name: selected.name, email: selected.email, roleId: selected.role_id, departmentId: selected.department_id || '', position: selected.position || '', password: '', editVersion: selected.edit_version }
: EMPTY_FORM,
label: selected ? `${t('common_edit')}: ${selected.name}` : t('user_new'), enabled: editing, exclude: ['password'],
});
const form = draft.values;
const setForm = draft.setValues;
const collaboratorRole = (roles || []).find((r) => r.code === 'collaborator');
const approveDraft = useDraftForm({
formId: 'user-approve', entityId: approving?.id,
initial: { roleId: approving?.role_id || collaboratorRole?.id || '', departmentId: '', editVersion: approving?.edit_version },
label: `${t('user_approve_title')}: ${approving?.name || ''}`, enabled: !!approving,
});
const approveForm = approveDraft.values;
const setApproveForm = approveDraft.setValues;
const pendingNew = hasDraftFor(useUserDrafts(), 'user-new', null);
useReopenDraft({ newFormId: canManage ? 'user-new' : null, editFormId: canManage ? 'user-edit' : null, items: users, openNew: () => startNew(), openEdit: (u) => startEdit(u) });
useReopenDraft({ editFormId: canManage ? 'user-approve' : null, items: users, openEdit: (u) => setApproving(u) });
const deptOptions = deptTree ? flatten(deptTree).map((d) => ({ value: d.id, label: d.name })) : [];
const roleOptions = (roles || []).map((r) => ({ value: r.id, label: r.name }));
function startNew() {
setSelected(null);
setForm(EMPTY_FORM);
setEditing(true);
}
function startEdit(user) {
if (!canManage) return;
setSelected(user);
setForm({ name: user.name, email: user.email, roleId: user.role_id, departmentId: user.department_id || '', position: user.position || '', password: '' });
setEditing(true);
}
async function save() {
if (!form.name.trim() || !form.email.trim() || !form.roleId) return;
await runAction(async () => {
if (!selected && !form.password) return;
const run = () => runAction(async () => {
if (selected) {
await updateUser(selected.id, { name: form.name.trim(), roleId: form.roleId, departmentId: form.departmentId || null, position: form.position || null });
await updateUser(selected.id, { name: form.name.trim(), roleId: form.roleId, departmentId: form.departmentId || null, position: form.position || null, editVersion: form.editVersion });
} else {
if (!form.password) return;
await createUser({ name: form.name.trim(), email: form.email.trim(), roleId: form.roleId, departmentId: form.departmentId || null, position: form.position || null, password: form.password });
}
draft.clear();
await reload();
setEditing(false);
pushToast({ tone: 'success', message: t('common_saved') });
});
if (selected) await draft.guardSave(run); else await run();
}
async function toggleActive(user) {
@@ -78,18 +101,17 @@ export default function UsersPanel() {
}
function startApprove(user) {
const collaboratorRole = (roles || []).find((r) => r.code === 'collaborator');
setApproving(user);
setApproveForm({ roleId: user.role_id || collaboratorRole?.id || '', departmentId: '' });
}
async function confirmApprove() {
await runAction(async () => {
await approveUser(approving.id, { roleId: approveForm.roleId, departmentId: approveForm.departmentId || null });
await approveDraft.guardSave(() => runAction(async () => {
await approveUser(approving.id, { roleId: approveForm.roleId, departmentId: approveForm.departmentId || null, editVersion: approveForm.editVersion });
approveDraft.clear();
setApproving(null);
await reload();
pushToast({ tone: 'success', message: t('common_saved') });
});
}));
}
function resendEmail(user) {
@@ -129,7 +151,7 @@ export default function UsersPanel() {
<div className="toolbar-left">
<SearchInput value={search} onChange={setSearch} />
</div>
{canManage && <Button variant="accent" icon={Plus} onClick={startNew}>{t('user_new')}</Button>}
{canManage && <Button variant="accent" icon={Plus} onClick={startNew}>{t('user_new')}{pendingNew && !editing && <DraftMark />}</Button>}
</div>
<ListView
@@ -164,51 +186,53 @@ export default function UsersPanel() {
} : undefined}
/>
<DetailPopup open={editing} title={selected ? t('common_edit') : t('user_new')} onClose={() => setEditing(false)} width={560} height={440}>
<DetailPopup open={editing} title={selected ? t('common_edit') : t('user_new')} onClose={() => guardedClose(draft, () => setEditing(false))} width={560} height={500}>
<DraftNotice draft={draft} />
<FieldRow>
<Field label={t('common_name')}>
<Field label={t('common_name')} dirty={draft.isFieldDirty('name')}>
<input className="input" value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} autoFocus />
</Field>
<Field label={t('user_email')}>
<Field label={t('user_email')} dirty={draft.isFieldDirty('email')}>
<input className="input" type="email" value={form.email} disabled={!!selected} onChange={(e) => setForm((f) => ({ ...f, email: e.target.value }))} />
</Field>
</FieldRow>
<FieldRow>
<Field label={t('user_role')}>
<Field label={t('user_role')} dirty={draft.isFieldDirty('roleId')}>
<ComboBox options={roleOptions} value={form.roleId} onChange={(v) => setForm((f) => ({ ...f, roleId: v }))} />
</Field>
<Field label={t('common_department')}>
<Field label={t('common_department')} dirty={draft.isFieldDirty('departmentId')}>
<ComboBox options={[{ value: '', label: '—' }, ...deptOptions]} value={form.departmentId} onChange={(v) => setForm((f) => ({ ...f, departmentId: v }))} />
</Field>
</FieldRow>
<FieldRow>
<Field label={t('user_position')}>
<Field label={t('user_position')} dirty={draft.isFieldDirty('position')}>
<input className="input" value={form.position} onChange={(e) => setForm((f) => ({ ...f, position: e.target.value }))} />
</Field>
{!selected && (
<Field label={t('user_password')}>
<Field label={t('user_password')} dirty={draft.isFieldDirty('password')}>
<PasswordInput value={form.password} onChange={(e) => setForm((f) => ({ ...f, password: e.target.value }))} />
</Field>
)}
</FieldRow>
<div style={{ display: 'flex', gap: 8 }}>
<Button variant="accent" icon={Save} onClick={save}>{t('common_save')}</Button>
<Button variant="standard" onClick={() => setEditing(false)}>{t('common_cancel')}</Button>
<Button variant="standard" onClick={() => { draft.discard(); setEditing(false); }}>{t('common_cancel')}</Button>
</div>
</DetailPopup>
<DetailPopup open={!!approving} title={t('user_approve_title')} onClose={() => setApproving(null)} width={420} height={300}>
<DetailPopup open={!!approving} title={t('user_approve_title')} onClose={() => guardedClose(approveDraft, () => setApproving(null))} width={420} height={340}>
<DraftNotice draft={approveDraft} />
<FieldRow>
<Field label={t('user_role')}>
<Field label={t('user_role')} dirty={approveDraft.isFieldDirty('roleId')}>
<ComboBox options={roleOptions} value={approveForm.roleId} onChange={(v) => setApproveForm((f) => ({ ...f, roleId: v }))} />
</Field>
<Field label={t('common_department')}>
<Field label={t('common_department')} dirty={approveDraft.isFieldDirty('departmentId')}>
<ComboBox options={[{ value: '', label: '—' }, ...deptOptions]} value={approveForm.departmentId} onChange={(v) => setApproveForm((f) => ({ ...f, departmentId: v }))} />
</Field>
</FieldRow>
<div style={{ display: 'flex', gap: 8 }}>
<Button variant="accent" icon={Check} onClick={confirmApprove}>{t('user_approve')}</Button>
<Button variant="standard" onClick={() => setApproving(null)}>{t('common_cancel')}</Button>
<Button variant="standard" onClick={() => { approveDraft.discard(); setApproving(null); }}>{t('common_cancel')}</Button>
</div>
</DetailPopup>
</div>