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
@@ -1,8 +1,8 @@
import { useState } from 'react';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { ArrowLeft } from 'lucide-react';
import { useUi } from '../../context/ui-context.jsx';
import { useAsync } from '../../hooks/useAsync.js';
import { useUserDrafts } from '../../hooks/useDraftForm.js';
import { getProject } from '../../services/projects.js';
import { Button } from '../../components/Button.jsx';
import { Tabs } from '../../components/Tabs.jsx';
@@ -20,10 +20,26 @@ export default function ProyectoDetallePage() {
const projectId = Number(id);
const { t } = useUi();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [searchParams, setSearchParams] = useSearchParams();
// La pestaña vive en la URL (?tab=): asi el retorno tras el login (o un F5) vuelve a la misma
// pestaña, y el aviso de cambios sin guardar intercepta el cambio de pestaña.
// Llegada desde el buscador (Proyectos): ?tab=activities&activity=<id>&activityTab=tasks abre
// directo la actividad (y su pestaña de tareas) que coincidio con la busqueda.
const [tab, setTab] = useState(searchParams.get('tab') || 'info');
const tab = searchParams.get('tab') || 'info';
const setTab = (key) => setSearchParams((prev) => {
const next = new URLSearchParams(prev);
next.set('tab', key);
next.delete('activity');
next.delete('activityTab');
return next;
}, { replace: true });
// Pestañas con borradores pendientes de este proyecto (el borrador guarda la URL con su ?tab=).
const drafts = useUserDrafts();
const dirtyTab = (key) => drafts.some((d) => {
if (typeof d.path !== 'string') return false;
const [path, query = ''] = d.path.split('?');
return path === `/proyectos/${projectId}` && (new URLSearchParams(query).get('tab') || 'info') === key;
});
const deepLinkActivityId = searchParams.get('activity') ? Number(searchParams.get('activity')) : null;
const deepLinkActivityTab = searchParams.get('activityTab') || undefined;
@@ -43,13 +59,13 @@ export default function ProyectoDetallePage() {
<Tabs
tabs={[
{ key: 'info', label: t('tab_info') },
{ key: 'members', label: t('tab_members'), count: project.members_count },
{ key: 'activities', label: t('tab_activities'), count: `${project.activities_complete}/${project.activities_total}` },
{ key: 'info', label: t('tab_info'), dirty: dirtyTab('info') },
{ key: 'members', dirty: dirtyTab('members'), label: t('tab_members'), count: project.members_count },
{ key: 'activities', dirty: dirtyTab('activities'), label: t('tab_activities'), count: `${project.activities_complete}/${project.activities_total}` },
{ key: 'gantt', label: t('tab_gantt') },
{ key: 'calendar', label: t('tab_calendar') },
{ key: 'comments', label: t('tab_comments'), count: project.comments_count },
{ key: 'documents', label: t('tab_documents'), count: project.documents_count },
{ key: 'comments', dirty: dirtyTab('comments'), label: t('tab_comments'), count: project.comments_count },
{ key: 'documents', dirty: dirtyTab('documents'), label: t('tab_documents'), count: project.documents_count },
]}
active={tab}
onChange={setTab}