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:
@@ -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') }] : []),
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user