se agrega los requerimientos de la LODPD con firma de documentos
This commit is contained in:
@@ -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