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
+38 -12
View File
@@ -1,10 +1,11 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { Send, Pencil, Trash2, Save, X } from 'lucide-react';
import { useUi } from '../context/ui-context.jsx';
import { useAuth } from '../context/auth-context.jsx';
import { useAsync, useAction } from '../hooks/useAsync.js';
import { listComments, createComment, updateComment, deleteComment } from '../services/comments.js';
import { Button } from './Button.jsx';
import { useDraftForm, useUserDrafts } from '../hooks/useDraftForm.js';
const MANAGE_ROLES = ['superadmin', 'admin'];
@@ -13,15 +14,35 @@ export function CommentsPanel({ entityType, entityId, onChanged }) {
const { user: currentUser } = useAuth();
const runAction = useAction();
const { data: comments, reload } = useAsync(() => listComments(entityType, entityId), [entityType, entityId]);
const [message, setMessage] = useState('');
const [editingId, setEditingId] = useState(null);
const [editingText, setEditingText] = useState('');
// Borradores: comentario nuevo (por entidad) y edicion de un comentario existente.
const scope = `${entityType}-${entityId}`;
const newDraft = useDraftForm({ formId: 'comment-new', entityId: scope, initial: { message: '' }, label: t('comment_placeholder') });
const message = newDraft.values.message;
const setMessage = (v) => newDraft.setField('message', v);
const editingComment = (comments || []).find((c) => c.id === editingId);
const editDraft = useDraftForm({
formId: 'comment-edit', entityId: editingId, initial: { message: editingComment?.message || '', editVersion: editingComment?.edit_version },
label: `${t('common_edit')}: ${(editingComment?.message || '').slice(0, 40)}`, enabled: !!editingComment, onDiscard: () => setEditingId(null),
});
const editingText = editDraft.values.message;
const setEditingText = (v) => editDraft.setField('message', v);
// Al volver con un borrador de edicion de un comentario de esta entidad, se reabre la edicion.
const drafts = useUserDrafts();
useEffect(() => {
if (!comments || editingId) return;
const d = drafts.find((x) => x.formId === 'comment-edit' && comments.some((c) => String(c.id) === String(x.entityId)));
if (d) {
setEditingId(Number(d.entityId));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [comments]);
async function send() {
if (!message.trim()) return;
await runAction(async () => {
await createComment(entityType, entityId, message.trim());
setMessage('');
newDraft.clear();
await reload();
onChanged?.(); // refresca el contador de la pestaña "Comentarios" en el padre
});
@@ -33,21 +54,21 @@ export function CommentsPanel({ entityType, entityId, onChanged }) {
function startEdit(c) {
setEditingId(c.id);
setEditingText(c.message);
}
function cancelEdit() {
editDraft.discard();
setEditingId(null);
setEditingText('');
}
async function saveEdit(id) {
if (!editingText.trim()) return;
await runAction(async () => {
await updateComment(id, editingText.trim());
cancelEdit();
await editDraft.guardSave(() => runAction(async () => {
await updateComment(id, editingText.trim(), editDraft.values.editVersion);
editDraft.clear();
setEditingId(null);
await reload();
});
}));
}
function confirmDelete(c) {
@@ -90,7 +111,8 @@ export function CommentsPanel({ entityType, entityId, onChanged }) {
{editingId === c.id ? (
<div style={{ marginTop: 6 }}>
<input
className="input" value={editingText} autoFocus
className={`input ${editDraft.isDirty ? 'input-dirty' : ''}`} value={editingText} autoFocus
aria-label={editDraft.isDirty ? `${t('common_edit')} (${t('draft_field_modified')})` : t('common_edit')}
onChange={(e) => setEditingText(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') saveEdit(c.id); if (e.key === 'Escape') cancelEdit(); }}
/>
@@ -105,7 +127,11 @@ export function CommentsPanel({ entityType, entityId, onChanged }) {
</div>
))}
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
<input className="input" value={message} placeholder={t('comment_placeholder')} onChange={(e) => setMessage(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && send()} />
<input
className={`input ${newDraft.isDirty ? 'input-dirty' : ''}`} value={message} placeholder={t('comment_placeholder')}
aria-label={newDraft.isDirty ? `${t('comment_placeholder')} (${t('draft_field_modified')})` : t('comment_placeholder')}
onChange={(e) => setMessage(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && send()}
/>
<Button variant="accent" icon={Send} onClick={send}>{t('comment_send')}</Button>
</div>
</div>