se agrega los requerimientos de la LODPD con firma de documentos

This commit is contained in:
2026-04-07 15:14:44 -05:00
parent 408a9b4435
commit 46a11728b8
31 changed files with 1432 additions and 52 deletions
+5
View File
@@ -13,6 +13,7 @@ import CertificatesPage from './pages/certificates/CertificatesPage';
import SignatureStylePage from './pages/signature-style/SignatureStylePage';
import SignaturesPage from './pages/signatures/SignaturesPage';
import VerifySignaturePage from './pages/verify/VerifySignaturePage';
import LegalPage from './pages/legal/LegalPage';
export default function App() {
return (
@@ -46,6 +47,10 @@ export default function App() {
path="/signatures"
element={<PrivateRoute><Layout><SignaturesPage /></Layout></PrivateRoute>}
/>
<Route
path="/legal"
element={<PrivateRoute><Layout><LegalPage /></Layout></PrivateRoute>}
/>
<Route path="*" element={<Navigate to="/dashboard" replace />} />
</Routes>
</AuthProvider>
+5 -1
View File
@@ -1,6 +1,10 @@
import client from './client';
export const register = (data) => client.post('/auth/register', data);
export const register = (data) => client.post('/auth/register', data, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
export const login = (data) => client.post('/auth/login', data);
export const logout = (refreshToken) => client.post('/auth/logout', { refreshToken });
export const getMe = () => client.get('/auth/me');
+7
View File
@@ -0,0 +1,7 @@
import client from './client';
export const listLegalDocuments = () => client.get('/legal');
export const downloadLegalDocument = (id) => client.get(`/legal/${id}/download`, {
responseType: 'blob',
});
+3
View File
@@ -3,3 +3,6 @@ import axios from 'axios';
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:4000';
export const verifySignature = (code) => axios.get(`${API_URL}/signatures/verify/${code}`);
export const getLegalTemplates = () => axios.get(`${API_URL}/legal/templates`);
export const getLegalTemplatePreview = (slug) =>
axios.get(`${API_URL}/legal/templates/${slug}/preview`, { responseType: 'blob' });
+10 -1
View File
@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react';
import { Link, useNavigate, useLocation } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext';
import { FileText, Shield, PenTool, LayoutDashboard, LogOut, Palette, Menu, X, KeyRound, Search, Plus, Users, Settings, ScrollText } from 'lucide-react';
import { FileText, Shield, PenTool, LayoutDashboard, LogOut, Palette, Menu, X, KeyRound, Search, Plus, Users, Settings, ScrollText, Scale } from 'lucide-react';
import ChangePasswordModal from '../ui/ChangePasswordModal';
const navSections = [
@@ -28,6 +28,7 @@ const topbarTitles = {
'/certificates': 'Firmantes',
'/signature-style': 'Configuración',
'/signatures': 'Firmar ahora',
'/legal': 'Legal',
};
export default function Layout({ children }) {
@@ -118,6 +119,14 @@ export default function Layout({ children }) {
<ScrollText size={15} />
<span>Auditoría</span>
</span>
<Link
to="/legal"
className={`nav-item ${location.pathname.startsWith('/legal') ? 'active' : ''}`}
onClick={() => setMobileMenuOpen(false)}
>
<Scale size={15} />
<span>Legal</span>
</Link>
</div>
</nav>
<div className="sidebar-footer">
+212
View File
@@ -431,6 +431,185 @@ select {
color: var(--gray-500);
}
.legal-register-box {
border: 1px solid var(--gray-300);
border-radius: 12px;
padding: 14px;
background: #f8fbff;
display: grid;
gap: 12px;
}
.legal-register-header {
display: flex;
gap: 10px;
align-items: flex-start;
color: var(--c-text);
}
.legal-register-header p {
margin-top: 3px;
color: var(--c-muted);
font-size: 12px;
}
.legal-register-list {
display: grid;
gap: 8px;
}
.legal-template-row {
border: 1px solid var(--gray-300);
border-radius: 10px;
padding: 12px 14px;
background: #fff;
}
.legal-template-stack {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 6px;
}
.legal-template-button {
border: none;
border-radius: 0;
padding: 0;
background: transparent;
color: var(--c-text);
display: flex;
align-items: flex-start;
gap: 8px;
cursor: pointer;
text-align: left;
}
.legal-template-button svg {
flex: 0 0 15px;
width: 15px;
height: 15px;
margin-top: 2px;
}
.legal-template-button:hover {
color: var(--primary);
}
.legal-template-button span {
font-weight: 600;
text-decoration: underline;
text-underline-offset: 2px;
}
.legal-template-version {
display: inline-flex;
align-items: center;
gap: 5px;
color: var(--c-muted);
font-weight: 500;
font-size: 12px;
}
.legal-template-version svg {
flex: 0 0 12px;
width: 12px;
height: 12px;
}
.legal-template-check {
display: flex;
align-items: center;
gap: 8px;
color: var(--c-text);
font-size: 12px;
white-space: nowrap;
}
.legal-template-check input {
accent-color: var(--primary);
flex: 0 0 15px;
width: 15px;
height: 15px;
opacity: 1;
}
.legal-template-check.is-disabled {
opacity: 1;
cursor: not-allowed;
}
.legal-template-check span {
color: var(--c-text);
}
.legal-template-check.is-disabled span {
color: var(--c-text);
}
.legal-register-status {
display: flex;
align-items: center;
gap: 8px;
color: var(--c-muted);
font-size: 12px;
}
.btn[disabled] {
opacity: 0.55;
cursor: not-allowed;
}
.legal-register-status.is-complete {
color: var(--success);
}
.legal-document-modal {
background: #fff;
border-radius: 12px;
width: min(840px, 95vw);
max-height: 92vh;
display: flex;
flex-direction: column;
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.35);
overflow: hidden;
}
.legal-document-body {
flex: 1;
overflow-y: auto;
display: flex;
justify-content: center;
padding: 16px;
background: var(--gray-100);
}
.legal-document-meta {
display: flex;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 14px;
font-size: 11px;
color: var(--gray-500);
}
.legal-document-summary {
margin-bottom: 16px;
color: var(--gray-700);
font-size: 13px;
line-height: 1.6;
}
.legal-document-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 16px;
border-top: 1px solid var(--gray-200);
background: var(--gray-50);
}
/* ─── Buttons ────────────────────────────────────────────────── */
.btn {
display: inline-flex; align-items: center; gap: 6px;
@@ -698,6 +877,12 @@ select {
gap: 14px;
}
.legal-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 14px;
}
.cert-card {
background: #fff;
border: 1px solid var(--gray-200);
@@ -736,6 +921,33 @@ select {
.form-card h3 { font-size: 14px; font-weight: 500; margin-bottom: 16px; color: var(--c-text); }
.form-card form { display: flex; flex-direction: column; gap: 14px; }
.legal-summary-card {
display: flex;
align-items: flex-start;
gap: 12px;
margin-bottom: 16px;
}
.legal-summary-icon {
width: 40px;
height: 40px;
border-radius: 999px;
display: inline-flex;
align-items: center;
justify-content: center;
background: var(--c-info-bg);
color: var(--c-primary);
flex-shrink: 0;
}
.legal-card {
min-height: 100%;
}
.legal-footer {
gap: 8px;
}
.form-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 8px; }
/* ─── Sign page ──────────────────────────────────────────────── */
+241 -10
View File
@@ -1,9 +1,46 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { register } from '../../api/auth';
import { PenTool } from 'lucide-react';
import { CheckSquare, FileCheck2, FileText, PenTool, ShieldCheck, Tag, X } from 'lucide-react';
import { Document, Page, pdfjs } from 'react-pdf';
import { getLegalTemplatePreview, getLegalTemplates } from '../../api/public';
import toast from 'react-hot-toast';
import 'react-pdf/dist/Page/AnnotationLayer.css';
import 'react-pdf/dist/Page/TextLayer.css';
pdfjs.GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/build/pdf.worker.min.mjs',
import.meta.url,
).toString();
const FALLBACK_LEGAL_TEMPLATES = [
{
slug: 'terms-of-use',
title: 'Términos de Uso de LinkedSign',
version: '1.1.0',
effectiveDate: '2026-04-07',
summary: 'Condiciones de acceso y uso de LinkedSign.',
sections: [],
},
{
slug: 'privacy-policy',
title: 'Política de Privacidad y Aviso de Tratamiento de Datos',
version: '1.1.0',
effectiveDate: '2026-04-07',
summary: 'Información sobre tratamiento de datos personales conforme a la LOPDP.',
sections: [],
},
{
slug: 'data-processing-consent',
title: 'Consentimiento Expreso para Tratamiento de Datos y Uso de Firma Electrónica',
version: '1.1.0',
effectiveDate: '2026-04-07',
summary: 'Aceptación expresa del tratamiento de datos y del uso del certificado para el alta inicial.',
sections: [],
},
];
const getPasswordChecks = (password = '') => ([
{ label: 'Al menos 8 caracteres', valid: password.length >= 8 },
{ label: 'Una mayúscula', valid: /[A-Z]/.test(password) },
@@ -14,12 +51,68 @@ const getPasswordChecks = (password = '') => ([
export default function RegisterPage() {
const navigate = useNavigate();
const [form, setForm] = useState({ email: '', password: '', confirmPassword: '' });
const [form, setForm] = useState({
email: '',
password: '',
confirmPassword: '',
certPassword: '',
consentAccepted: false,
});
const [certificateFile, setCertificateFile] = useState(null);
const [legalTemplates, setLegalTemplates] = useState([]);
const [openedTemplates, setOpenedTemplates] = useState({});
const [acceptedTemplates, setAcceptedTemplates] = useState({});
const [activeTemplate, setActiveTemplate] = useState(null);
const [viewerBlob, setViewerBlob] = useState(null);
const [loadingViewer, setLoadingViewer] = useState(false);
const [numPages, setNumPages] = useState(null);
const [currentPage, setCurrentPage] = useState(1);
const [loading, setLoading] = useState(false);
const passwordChecks = getPasswordChecks(form.password);
const isPasswordStrong = passwordChecks.every((check) => check.valid);
const templatesToRender = legalTemplates.length > 0 ? legalTemplates : FALLBACK_LEGAL_TEMPLATES;
const allLegalAccepted =
templatesToRender.length > 0 && templatesToRender.every((template) => acceptedTemplates[template.slug]);
const handleChange = (e) => setForm((f) => ({ ...f, [e.target.name]: e.target.value }));
useEffect(() => {
getLegalTemplates()
.then(({ data }) => setLegalTemplates(data.data || []))
.catch(() => {
setLegalTemplates([]);
});
}, []);
const handleChange = (e) => {
const { name, value, checked, type } = e.target;
setForm((f) => ({ ...f, [name]: type === 'checkbox' ? checked : value }));
};
const handleOpenTemplate = (template) => {
setActiveTemplate(template);
setOpenedTemplates((current) => ({ ...current, [template.slug]: true }));
setCurrentPage(1);
setNumPages(null);
setLoadingViewer(true);
getLegalTemplatePreview(template.slug)
.then(({ data }) => setViewerBlob(data))
.catch(() => {
setViewerBlob(null);
toast.error('No se pudo abrir el documento legal');
})
.finally(() => setLoadingViewer(false));
};
const handleTemplateAcceptance = (slug, checked) => {
setAcceptedTemplates((current) => ({ ...current, [slug]: checked }));
};
const closeViewer = () => {
setActiveTemplate(null);
setViewerBlob(null);
setLoadingViewer(false);
setNumPages(null);
setCurrentPage(1);
};
const handleSubmit = async (e) => {
e.preventDefault();
@@ -31,13 +124,29 @@ export default function RegisterPage() {
toast.error('Las contraseñas no coinciden');
return;
}
if (!certificateFile) {
toast.error('Debes cargar tu certificado P12/PFX');
return;
}
if (!form.certPassword) {
toast.error('Debes ingresar la contraseña del certificado');
return;
}
if (!allLegalAccepted) {
toast.error('Debes leer y aceptar cada documento legal para completar el registro');
return;
}
setLoading(true);
try {
await register({
email: form.email,
password: form.password,
});
toast.success('Cuenta creada. Revisa tu correo para confirmar tu usuario.');
const payload = new FormData();
payload.append('email', form.email);
payload.append('password', form.password);
payload.append('certPassword', form.certPassword);
payload.append('consentAccepted', 'true');
payload.append('certificateFile', certificateFile);
await register(payload);
toast.success('Cuenta creada. Revisa tu correo para confirmar tu usuario y recibir los documentos legales firmados.');
navigate('/login');
} catch (err) {
toast.error(err.response?.data?.error || 'Error al registrarse');
@@ -78,7 +187,74 @@ export default function RegisterPage() {
<label>Confirmar contraseña</label>
<input type="password" name="confirmPassword" value={form.confirmPassword} onChange={handleChange} required />
</div>
<button type="submit" className="btn btn-primary btn-full" disabled={loading}>
<div className="field">
<label>Certificado P12/PFX</label>
<input type="file" accept=".p12,.pfx" onChange={(e) => setCertificateFile(e.target.files?.[0] || null)} required />
</div>
<div className="field">
<label>Contraseña del certificado</label>
<input type="password" name="certPassword" value={form.certPassword} onChange={handleChange} required />
<small className="field-hint">
Esta contraseña solo se usa para firmar los documentos legales del registro inicial. No se guarda.
</small>
</div>
<div className="legal-register-box">
<div className="legal-register-header">
<ShieldCheck size={18} />
<div>
<strong>Documentos legales del registro</strong>
<p>Debes abrir cada documento, leerlo y aceptarlo. Se firmarán automáticamente al final de cada documento con tu propio certificado.</p>
</div>
</div>
<div className="legal-register-list">
{templatesToRender.map((template) => (
<div key={template.slug} className="legal-template-row">
<div className="legal-template-stack">
<button
type="button"
className="legal-template-button"
onClick={() => handleOpenTemplate(template)}
>
<FileText size={15} />
<span>{template.title}</span>
</button>
<small className="legal-template-version">
<Tag size={12} />
<span>Versión {template.version}</span>
</small>
<label className={`legal-template-check ${openedTemplates[template.slug] ? '' : 'is-disabled'}`}>
<input
type="checkbox"
checked={Boolean(acceptedTemplates[template.slug])}
onChange={(event) => {
if (!openedTemplates[template.slug]) {
event.preventDefault();
toast.error('Primero debes abrir y leer este documento');
return;
}
handleTemplateAcceptance(template.slug, event.target.checked);
}}
/>
<span>He leído y acepto</span>
</label>
</div>
</div>
))}
</div>
<div className={`legal-register-status ${allLegalAccepted ? 'is-complete' : ''}`}>
<CheckSquare size={16} />
<span>
{allLegalAccepted
? 'Todos los documentos legales fueron leídos y aceptados.'
: 'Debes abrir y aceptar cada documento para continuar.'}
</span>
</div>
</div>
<button
type="submit"
className="btn btn-primary btn-full"
disabled={loading || !allLegalAccepted}
>
{loading ? 'Registrando...' : 'Crear cuenta'}
</button>
</form>
@@ -86,6 +262,61 @@ export default function RegisterPage() {
¿Ya tienes cuenta? <Link to="/login">Iniciar sesión</Link>
</p>
</div>
{activeTemplate && (
<div className="modal-overlay" onClick={closeViewer}>
<div className="legal-document-modal" onClick={(event) => event.stopPropagation()}>
<div className="pdf-viewer-header">
<div className="pdf-viewer-title">
<FileCheck2 size={18} />
<span>{activeTemplate.title}</span>
</div>
<div className="pdf-viewer-controls">
{numPages && (
<>
<button type="button" className="btn btn-ghost" disabled={currentPage <= 1} onClick={() => setCurrentPage((page) => page - 1)}>‹</button>
<span>{currentPage} / {numPages}</span>
<button type="button" className="btn btn-ghost" disabled={currentPage >= numPages} onClick={() => setCurrentPage((page) => page + 1)}>›</button>
</>
)}
<button type="button" className="btn-icon" onClick={closeViewer} aria-label="Cerrar">
<X size={18} />
</button>
</div>
</div>
<div className="pdf-viewer-body">
{loadingViewer ? (
<div className="loading-area"><div className="spinner" /></div>
) : viewerBlob ? (
<Document
file={viewerBlob}
onLoadSuccess={({ numPages: totalPages }) => setNumPages(totalPages)}
loading={<div className="loading-area"><div className="spinner" /></div>}
>
<Page pageNumber={currentPage} width={720} />
</Document>
) : (
<div className="loading-area">No se pudo cargar la vista previa.</div>
)}
</div>
<div className="legal-document-footer">
<div className="legal-document-meta">
<span>Versión {activeTemplate.version}</span>
<span>Vigente desde {activeTemplate.effectiveDate}</span>
</div>
<button type="button" className="btn btn-primary" onClick={closeViewer}>
Cerrar
</button>
</div>
</div>
</div>
)}
</div>
);
}
const closeViewer = () => {
setActiveTemplate(null);
setViewerBlob(null);
setLoadingViewer(false);
setNumPages(null);
setCurrentPage(1);
};
+160
View File
@@ -0,0 +1,160 @@
import { useEffect, useState } from 'react';
import { Download, Eye, FileCheck, FileText, Shield, X } from 'lucide-react';
import toast from 'react-hot-toast';
import { Document, Page, pdfjs } from 'react-pdf';
import { listLegalDocuments, downloadLegalDocument } from '../../api/legal';
import 'react-pdf/dist/Page/AnnotationLayer.css';
import 'react-pdf/dist/Page/TextLayer.css';
pdfjs.GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/build/pdf.worker.min.mjs',
import.meta.url,
).toString();
const formatDate = (value) => new Date(value).toLocaleString('es-EC');
export default function LegalPage() {
const [items, setItems] = useState([]);
const [loading, setLoading] = useState(true);
const [viewerBlob, setViewerBlob] = useState(null);
const [viewerTitle, setViewerTitle] = useState('');
const [numPages, setNumPages] = useState(null);
const [currentPage, setCurrentPage] = useState(1);
const [loadingViewer, setLoadingViewer] = useState(false);
useEffect(() => {
listLegalDocuments()
.then(({ data }) => setItems(data.data || []))
.catch(() => toast.error('No se pudieron cargar los documentos legales'))
.finally(() => setLoading(false));
}, []);
const handleView = async (id) => {
try {
setLoadingViewer(true);
setCurrentPage(1);
setNumPages(null);
const { data } = await downloadLegalDocument(id);
setViewerBlob(data);
} catch {
toast.error('No se pudo abrir el documento legal');
} finally {
setLoadingViewer(false);
}
};
const handleDownload = async (id, filename) => {
try {
const { data } = await downloadLegalDocument(id);
const blobUrl = URL.createObjectURL(new Blob([data], { type: 'application/pdf' }));
const anchor = document.createElement('a');
anchor.href = blobUrl;
anchor.download = filename;
anchor.click();
setTimeout(() => URL.revokeObjectURL(blobUrl), 10_000);
} catch {
toast.error('No se pudo descargar el documento legal');
}
};
const closeViewer = () => {
setViewerBlob(null);
setViewerTitle('');
};
return (
<div className="page">
{(viewerBlob || loadingViewer) && (
<div className="modal-overlay" onClick={closeViewer}>
<div className="pdf-viewer-modal" onClick={(event) => event.stopPropagation()}>
<div className="pdf-viewer-header">
<span className="pdf-viewer-title"><FileText size={16} /> {viewerTitle}</span>
<div className="pdf-viewer-controls">
{numPages && (
<>
<button className="btn btn-ghost" disabled={currentPage <= 1} onClick={() => setCurrentPage((page) => page - 1)}>‹</button>
<span>{currentPage} / {numPages}</span>
<button className="btn btn-ghost" disabled={currentPage >= numPages} onClick={() => setCurrentPage((page) => page + 1)}>›</button>
</>
)}
<button className="btn-icon" onClick={closeViewer}><X size={18} /></button>
</div>
</div>
<div className="pdf-viewer-body">
{loadingViewer ? (
<div className="loading-area"><div className="spinner" /></div>
) : (
<Document
file={viewerBlob}
onLoadSuccess={({ numPages: totalPages }) => setNumPages(totalPages)}
loading={<div className="loading-area"><div className="spinner" /></div>}
>
<Page pageNumber={currentPage} width={720} />
</Document>
)}
</div>
</div>
</div>
)}
<div className="page-header">
<div>
<h2>Legal</h2>
<p>Documentos legales firmados durante tu registro. Puedes verlos y descargarlos, pero no eliminarlos.</p>
</div>
</div>
<div className="form-card legal-summary-card">
<div className="legal-summary-icon">
<Shield size={20} />
</div>
<div>
<h3>Consentimiento legal de registro</h3>
<p>
Los documentos de esta sección fueron firmados con tu propio certificado digital en el primer registro.
La contraseña del certificado se usó solo en ese momento y no fue almacenada.
</p>
</div>
</div>
{loading ? (
<div className="loading-area"><div className="spinner" /></div>
) : items.length === 0 ? (
<div className="empty-state">
<FileCheck size={48} />
<p>Aún no hay documentos legales asociados a tu cuenta.</p>
</div>
) : (
<div className="legal-grid">
{items.map((item) => (
<div key={item.id} className="cert-card legal-card">
<div className="cert-header">
<FileCheck size={20} />
<span className="cert-alias">{item.title}</span>
<span className="badge badge-signed">Firmado</span>
</div>
<div className="cert-details">
<p><strong>Versión:</strong> {item.version}</p>
<p><strong>Vigente desde:</strong> {item.effectiveDate}</p>
<p><strong>Firmado el:</strong> {formatDate(item.createdAt)}</p>
<p><strong>Archivo:</strong> {item.signedFilename}</p>
</div>
<div className="cert-footer legal-footer">
<button className="btn btn-ghost" type="button" onClick={() => {
setViewerTitle(item.title);
handleView(item.id);
}}>
<Eye size={15} /> Ver
</button>
<button className="btn btn-primary" type="button" onClick={() => handleDownload(item.id, item.signedFilename)}>
<Download size={15} /> Descargar
</button>
</div>
</div>
))}
</div>
)}
</div>
);
}