se agrega los requerimientos de la LODPD con firma de documentos
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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,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' });
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 ──────────────────────────────────────────────── */
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user