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
+60 -27
View File
@@ -1,4 +1,5 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { Navigate } from 'react-router-dom';
import { Pencil, Save, Plus, Trash2, Power, Copy, Download } from 'lucide-react';
import { useUi } from '../../context/ui-context.jsx';
@@ -15,6 +16,8 @@ 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 { useDraftForm, useUserDrafts } from '../../hooks/useDraftForm.js';
import { SearchInput } from '../../components/SearchInput.jsx';
import { ListView } from '../../components/ListView.jsx';
import { Tabs } from '../../components/Tabs.jsx';
@@ -53,56 +56,56 @@ function SimpleTable({ columns, rows, empty }) {
);
}
function DeviceDetail({ id, onClose, onChanged, canEdit }) {
function DeviceDetail({ id, onClose, onChanged, canEdit, tab = 'general', onTabChange }) {
const { t, pushToast } = useUi();
const runAction = useAction();
const { data: device, reload } = useAsync(() => getDevice(id), [id]);
const { data: users } = useAsync(listUsers, []);
const { data: departments } = useAsync(listDepartments, []);
const [tab, setTab] = useState('general');
const setTab = onTabChange; // la pestaña vive en la URL (?deviceTab=)
const [searches, setSearches] = useState({});
const [showComponents, setShowComponents] = useState(false);
const [editing, setEditing] = useState(false);
const [form, setForm] = useState({});
// Borrador de los datos editables del equipo (estado, asignado, departamento, notas).
const draft = useDraftForm({
formId: 'device-edit', entityId: id,
initial: device ? { status: device.status, assignedUserId: device.assigned_user_id || '', departmentId: device.department_id || '', notes: device.notes || '', editVersion: device.edit_version } : {},
label: `${t('eq_detail')}: ${device?.hostname || id}`, enabled: !!device && canEdit && tab === 'general', onDiscard: () => setEditing(false),
});
const form = draft.values;
const setForm = draft.setValues;
useEffect(() => { if (draft.restored) setEditing(true); }, [draft.restored]);
const flat = (list) => (list || []).flatMap((d) => [d, ...(d.children ? flat(d.children) : [])]);
const userOptions = (users || []).map((u) => ({ value: u.id, label: u.name }));
const deptOptions = flat(departments).map((d) => ({ value: d.id, label: d.name }));
function startEdit() {
setForm({
status: device.status,
assignedUserId: device.assigned_user_id || '',
departmentId: device.department_id || '',
notes: device.notes || '',
});
setEditing(true);
}
function save() {
const changed = form.status !== device.status
|| Number(form.assignedUserId || 0) !== Number(device.assigned_user_id || 0)
|| Number(form.departmentId || 0) !== Number(device.department_id || 0)
|| form.notes !== (device.notes || '');
if (!changed) { setEditing(false); return; }
runAction(async () => {
if (!draft.isDirty) { draft.discard(); setEditing(false); return; }
draft.guardSave(() => runAction(async () => {
await updateDevice(id, {
status: form.status,
assignedUserId: form.assignedUserId ? Number(form.assignedUserId) : null,
departmentId: form.departmentId ? Number(form.departmentId) : null,
notes: form.notes,
editVersion: form.editVersion,
});
draft.clear();
setEditing(false);
await reload();
onChanged();
pushToast({ tone: 'success', message: t('common_saved') });
});
}), { fetchCurrent: async () => { const d = await getDevice(id); return { status: d.status, assignedUserId: d.assigned_user_id || '', departmentId: d.department_id || '', notes: d.notes || '' }; } });
}
if (!device) return <div className="text-muted">{t('common_loading')}</div>;
const tabs = [
{ key: 'general', label: t('eq_tab_general') },
{ key: 'general', label: t('eq_tab_general'), dirty: editing && draft.isDirty },
{ key: 'users', label: t('eq_tab_users'), count: device.accounts.filter((a) => a.kind === 'local_user').length },
{ key: 'hardware', label: t('eq_tab_hardware') },
{ key: 'network', label: t('eq_tab_network'), count: device.networks.length },
@@ -191,26 +194,27 @@ function DeviceDetail({ id, onClose, onChanged, canEdit }) {
</>
) : (
<>
<Field label={t('common_status')}>
<DraftNotice draft={draft} />
<Field label={t('common_status')} dirty={draft.isFieldDirty('status')}>
<ComboBox
options={[{ value: 'active', label: t('eq_status_active') }, { value: 'retired', label: t('eq_status_retired') }]}
value={form.status} onChange={(v) => setForm({ ...form, status: v })}
/>
</Field>
<Field label={t('eq_assigned_to')}>
<Field label={t('eq_assigned_to')} dirty={draft.isFieldDirty('assignedUserId')}>
<ComboBox options={[{ value: '', label: '—' }, ...userOptions]} value={form.assignedUserId} onChange={(v) => setForm({ ...form, assignedUserId: 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({ ...form, departmentId: v })} />
</Field>
<Row label={t('eq_first_seen')}>{fmtDate(device.first_seen)}</Row>
<Row label={t('eq_last_seen')}>{fmtDate(device.last_seen)}</Row>
<Field label={t('eq_notes')}>
<Field label={t('eq_notes')} dirty={draft.isFieldDirty('notes')}>
<textarea className="textarea" rows={3} value={form.notes} onChange={(e) => setForm({ ...form, notes: e.target.value })} />
</Field>
<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>
</>
)}
@@ -315,8 +319,28 @@ function DevicesTab({ canEdit }) {
const [search, setSearch] = useState('');
const [kind, setKind] = useState('');
const [status, setStatus] = useState('active');
const [selected, setSelected] = useState(null); // { id, view: 'info' }
// Equipo abierto y su pestaña en la URL (?device=&deviceTab=): F5, retorno tras el login y
// aviso de cambios sin guardar al cerrar el popup.
const [searchParams, setSearchParams] = useSearchParams();
const deviceId = searchParams.get('device') ? Number(searchParams.get('device')) : null;
const deviceTab = searchParams.get('deviceTab') || 'general';
const setDeviceParams = (id, tab) => setSearchParams((prev) => {
const next = new URLSearchParams(prev);
if (id) { next.set('device', id); if (tab && tab !== 'general') next.set('deviceTab', tab); else next.delete('deviceTab'); }
else { next.delete('device'); next.delete('deviceTab'); }
return next;
}, { replace: true });
const selected = deviceId ? { id: deviceId, view: 'info' } : null;
const setSelected = (v) => setDeviceParams(v?.id || null);
const { data: devices, reload } = useAsync(() => listDevices({ kind, status }), [kind, status]);
// Al volver con un borrador de un equipo (que el usuario sigue viendo), se reabre su popup.
const drafts = useUserDrafts();
useEffect(() => {
if (!devices || deviceId) return;
const d = drafts.find((x) => x.formId === 'device-edit' && devices.some((dv) => String(dv.id) === String(x.entityId)));
if (d) setDeviceParams(Number(d.entityId));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [devices]);
const kindOptions = [{ value: '', label: t('common_all') }, ...KINDS.map((k) => ({ value: k, label: t(`eq_kind_${k}`) }))];
const statusOptions = [
@@ -352,7 +376,12 @@ function DevicesTab({ canEdit }) {
/>
)}
<DetailPopup open={selected?.view === 'info'} title={t('eq_detail')} onClose={() => setSelected(null)} width={860} height={680}>
{selected?.view === 'info' && <DeviceDetail key={selected.id} id={selected.id} onClose={() => setSelected(null)} onChanged={reload} canEdit={canEdit} />}
{selected?.view === 'info' && (
<DeviceDetail
key={selected.id} id={selected.id} onClose={() => setSelected(null)} onChanged={reload} canEdit={canEdit}
tab={deviceTab} onTabChange={(k) => setDeviceParams(selected.id, k)}
/>
)}
</DetailPopup>
</>
);
@@ -566,12 +595,16 @@ function AgentsTab() {
export default function EquiposPage() {
const { t } = useUi();
const { data: access } = useAsync(getDevicesAccess, []);
const [tab, setTab] = useState('summary');
const [searchParams, setSearchParams] = useSearchParams();
const tab = searchParams.get('tab') || 'summary';
const setTab = (key) => setSearchParams({ tab: key }, { replace: true });
const drafts = useUserDrafts();
const devicesDirty = drafts.some((d) => d.formId === 'device-edit');
if (!access) return <div className="text-muted">{t('common_loading')}</div>;
if (!access.canView) return <Navigate to="/" replace />;
const tabs = [
{ key: 'summary', label: t('eq_tab_summary') },
{ key: 'devices', label: t('eq_tab_devices') },
{ key: 'devices', label: t('eq_tab_devices'), dirty: devicesDirty },
{ key: 'software', label: t('eq_tab_software_all') },
...(access.canManage ? [{ key: 'agents', label: t('eq_tab_agents') }, { key: 'share', label: t('eq_tab_share') }] : []),
];