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
@@ -7,13 +7,16 @@ import {
completeRecurringTaskCycle,
} from '../../services/recurringTasks.js';
import { listUsers } from '../../services/users.js';
import { listProjects } from '../../services/projects.js';
import { listProjectOptions } from '../../services/projects.js';
import { ListView } from '../../components/ListView.jsx';
import { Button } from '../../components/Button.jsx';
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';
const HOUR_OPTIONS = Array.from({ length: 24 }, (_, h) => ({ value: h, label: `${String(h).padStart(2, '0')}:00` }));
@@ -28,7 +31,7 @@ function toForm(r) {
name: r.name, description: r.description || '', projectId: r.project_id || '',
responsibleUserId: r.responsible_user_id || '', intervalUnit: r.interval_unit,
intervalValue: r.interval_value, nextDueDate: r.next_due_date, endDate: r.end_date || '',
reminderDaysBefore: r.reminder_days_before, reminderHour: r.reminder_hour,
reminderDaysBefore: r.reminder_days_before, reminderHour: r.reminder_hour, editVersion: r.edit_version,
};
}
@@ -37,11 +40,24 @@ export default function RecurringTasksPage() {
const runAction = useAction();
const { data: items, reload } = useAsync(listRecurringTasks, []);
const { data: users } = useAsync(listUsers, []);
const { data: projects } = useAsync(() => listProjects(), []);
const { data: projects } = useAsync(listProjectOptions, []);
const [editing, setEditing] = useState(false);
const [selected, setSelected] = useState(null);
const [form, setForm] = useState(EMPTY_FORM);
// Borrador del popup (nueva o edicion de una existente).
const draft = useDraftForm({
formId: selected ? 'recurring-edit' : 'recurring-new', entityId: selected?.id ?? null,
initial: selected ? toForm(selected) : EMPTY_FORM,
label: selected ? `${t('common_edit')}: ${selected.name}` : t('recurring_task_new'), enabled: editing,
});
const form = draft.values;
const setForm = draft.setValues;
const guardedClose = useGuardedClose();
const pendingNew = hasDraftFor(useUserDrafts(), 'recurring-new', null);
useReopenDraft({
newFormId: 'recurring-new', editFormId: 'recurring-edit', items,
openNew: () => startNew(), openEdit: (r) => startEdit(r),
});
const userOptions = (users || []).map((u) => ({ value: u.id, label: u.name }));
const projectOptions = (projects || []).map((p) => ({ value: p.id, label: `${p.code} — ${p.name}` }));
@@ -54,26 +70,27 @@ export default function RecurringTasksPage() {
function startNew() {
setSelected(null);
setForm(EMPTY_FORM);
setEditing(true);
}
function startEdit(r) {
setSelected(r);
setForm(toForm(r));
setEditing(true);
}
async function save() {
if (!form.name.trim() || !form.nextDueDate) return;
await runAction(async () => {
const run = () => runAction(async () => {
const payload = { ...form, projectId: form.projectId || null, responsibleUserId: form.responsibleUserId || null, endDate: form.endDate || null };
if (selected) await updateRecurringTask(selected.id, payload);
else await createRecurringTask(payload);
draft.clear();
setEditing(false);
pushToast({ tone: 'success', message: t('common_saved') });
await reload();
});
// Edicion: si el registro cambio en el servidor desde que se empezo a editar, se confirma.
if (selected) await draft.guardSave(run); else await run();
}
function confirmDelete(r) {
@@ -135,7 +152,7 @@ export default function RecurringTasksPage() {
<div className="toolbar-left">
<h2 className="page-title mt-0">{t('nav_recurring_tasks')}</h2>
</div>
<Button variant="accent" icon={Plus} onClick={startNew}>{t('recurring_task_new')}</Button>
<Button variant="accent" icon={Plus} onClick={startNew}>{t('recurring_task_new')}{pendingNew && !editing && <DraftMark />}</Button>
</div>
<ListView
@@ -154,51 +171,52 @@ export default function RecurringTasksPage() {
<DetailPopup
open={editing}
title={selected ? t('common_edit') : t('recurring_task_new')}
onClose={() => setEditing(false)}
onClose={() => guardedClose(draft, () => setEditing(false))}
width={640}
height={720}
height={760}
>
<Field label={t('common_name')}>
<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('common_description')}>
<Field label={t('common_description')} dirty={draft.isFieldDirty('description')}>
<textarea className="textarea" value={form.description} onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))} />
</Field>
<FieldRow>
<Field label={t('recurring_task_project')}>
<Field label={t('recurring_task_project')} dirty={draft.isFieldDirty('projectId')}>
<ComboBox options={[{ value: '', label: '—' }, ...projectOptions]} value={form.projectId} onChange={(v) => setForm((f) => ({ ...f, projectId: v }))} />
</Field>
<Field label={t('common_responsible')}>
<Field label={t('common_responsible')} dirty={draft.isFieldDirty('responsibleUserId')}>
<ComboBox options={[{ value: '', label: '—' }, ...userOptions]} value={form.responsibleUserId} onChange={(v) => setForm((f) => ({ ...f, responsibleUserId: v }))} />
</Field>
</FieldRow>
<FieldRow>
<Field label={t('recurring_task_every')}>
<Field label={t('recurring_task_every')} dirty={draft.isFieldDirty('intervalValue')}>
<input className="input" type="number" min={1} value={form.intervalValue} onChange={(e) => setForm((f) => ({ ...f, intervalValue: Number(e.target.value) }))} />
</Field>
<Field label={t('recurring_task_interval_unit')}>
<Field label={t('recurring_task_interval_unit')} dirty={draft.isFieldDirty('intervalUnit')}>
<ComboBox options={unitOptions} value={form.intervalUnit} onChange={(v) => setForm((f) => ({ ...f, intervalUnit: v }))} />
</Field>
<Field label={t('recurring_task_next_due')}>
<Field label={t('recurring_task_next_due')} dirty={draft.isFieldDirty('nextDueDate')}>
<input className="input" type="date" value={form.nextDueDate} onChange={(e) => setForm((f) => ({ ...f, nextDueDate: e.target.value }))} />
</Field>
</FieldRow>
<FieldRow>
<Field label={t('recurring_task_end_date')} hint={t('recurring_task_unlimited_hint')}>
<Field label={t('recurring_task_end_date')} hint={t('recurring_task_unlimited_hint')} dirty={draft.isFieldDirty('endDate')}>
<input className="input" type="date" value={form.endDate} onChange={(e) => setForm((f) => ({ ...f, endDate: e.target.value }))} />
</Field>
</FieldRow>
<FieldRow>
<Field label={t('recurring_task_reminder_days')}>
<Field label={t('recurring_task_reminder_days')} dirty={draft.isFieldDirty('reminderDaysBefore')}>
<input className="input" type="number" min={0} value={form.reminderDaysBefore} onChange={(e) => setForm((f) => ({ ...f, reminderDaysBefore: Number(e.target.value) }))} />
</Field>
<Field label={t('recurring_task_reminder_hour')}>
<Field label={t('recurring_task_reminder_hour')} dirty={draft.isFieldDirty('reminderHour')}>
<ComboBox options={HOUR_OPTIONS} value={form.reminderHour} onChange={(v) => setForm((f) => ({ ...f, reminderHour: Number(v) }))} />
</Field>
</FieldRow>
<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>
</div>
</DetailPopup>
</div>