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