Initial LinkedSign platform

This commit is contained in:
2026-04-06 20:33:59 -05:00
commit 1074c7c9d2
159 changed files with 19072 additions and 0 deletions
@@ -0,0 +1,57 @@
import { useEffect, useState } from 'react';
import { Link, useParams } from 'react-router-dom';
import { CheckCircle2, MailWarning, LoaderCircle } from 'lucide-react';
import { confirmPasswordChange } from '../../api/auth';
export default function ConfirmPasswordChangePage() {
const { token } = useParams();
const [status, setStatus] = useState('loading');
const [message, setMessage] = useState('Confirmando cambio de contraseña...');
useEffect(() => {
let active = true;
confirmPasswordChange(token)
.then(({ data }) => {
if (!active) return;
setStatus('success');
setMessage(data.data.message || 'Contraseña cambiada correctamente.');
})
.catch((error) => {
if (!active) return;
setStatus('error');
setMessage(error.response?.data?.error || 'El enlace no es válido o ya expiró.');
});
return () => {
active = false;
};
}, [token]);
return (
<div className="auth-page">
<div className="auth-card">
<div className="auth-logo">
{status === 'loading' && <LoaderCircle size={32} className="spin-soft" />}
{status === 'success' && <CheckCircle2 size={32} />}
{status === 'error' && <MailWarning size={32} />}
<h1>Cambio de contraseña</h1>
<p>{message}</p>
</div>
<div className="auth-form">
{status === 'success' && (
<Link to="/login" className="btn btn-primary btn-full">
Ir a iniciar sesión
</Link>
)}
{status === 'error' && (
<Link to="/dashboard" className="btn btn-secondary btn-full">
Volver
</Link>
)}
</div>
</div>
</div>
);
}
+64
View File
@@ -0,0 +1,64 @@
import { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext';
import { PenTool } from 'lucide-react';
import toast from 'react-hot-toast';
export default function LoginPage() {
const { login } = useAuth();
const navigate = useNavigate();
const [form, setForm] = useState({ email: '', password: '' });
const [loading, setLoading] = useState(false);
const handleChange = (e) => setForm((f) => ({ ...f, [e.target.name]: e.target.value }));
const handleSubmit = async (e) => {
e.preventDefault();
setLoading(true);
try {
await login(form.email, form.password);
navigate('/dashboard');
} catch (err) {
const code = err.response?.data?.code;
const message = err.response?.data?.error || 'Error al iniciar sesión';
if (code === 'EMAIL_NOT_VERIFIED') {
toast.success('Tu cuenta aún no está validada. Te enviamos un nuevo correo de confirmación.');
} else if (code === 'MAIL_SEND_FAILED') {
toast.error(message);
} else {
toast.error(message);
}
} finally {
setLoading(false);
}
};
return (
<div className="auth-page">
<div className="auth-card">
<div className="auth-logo">
<PenTool size={32} />
<h1>LinkedSign</h1>
<p>Firma digital segura</p>
</div>
<form onSubmit={handleSubmit} className="auth-form">
<div className="field">
<label>Correo electrónico</label>
<input type="email" name="email" value={form.email} onChange={handleChange} required autoFocus />
</div>
<div className="field">
<label>Contraseña</label>
<input type="password" name="password" value={form.password} onChange={handleChange} required />
</div>
<button type="submit" className="btn btn-primary btn-full" disabled={loading}>
{loading ? 'Ingresando...' : 'Iniciar sesión'}
</button>
</form>
<p className="auth-link">
¿No tienes cuenta? <Link to="/register">Registrarse</Link>
</p>
</div>
</div>
);
}
+91
View File
@@ -0,0 +1,91 @@
import { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { register } from '../../api/auth';
import { PenTool } from 'lucide-react';
import toast from 'react-hot-toast';
const getPasswordChecks = (password = '') => ([
{ label: 'Al menos 8 caracteres', valid: password.length >= 8 },
{ label: 'Una mayúscula', valid: /[A-Z]/.test(password) },
{ label: 'Una minúscula', valid: /[a-z]/.test(password) },
{ label: 'Un número', valid: /\d/.test(password) },
{ label: 'Un carácter especial', valid: /[^A-Za-z0-9]/.test(password) },
]);
export default function RegisterPage() {
const navigate = useNavigate();
const [form, setForm] = useState({ email: '', password: '', confirmPassword: '' });
const [loading, setLoading] = useState(false);
const passwordChecks = getPasswordChecks(form.password);
const isPasswordStrong = passwordChecks.every((check) => check.valid);
const handleChange = (e) => setForm((f) => ({ ...f, [e.target.name]: e.target.value }));
const handleSubmit = async (e) => {
e.preventDefault();
if (!isPasswordStrong) {
toast.error('La contraseña no cumple con los requisitos de seguridad');
return;
}
if (form.password !== form.confirmPassword) {
toast.error('Las contraseñas no coinciden');
return;
}
setLoading(true);
try {
await register({
email: form.email,
password: form.password,
});
toast.success('Cuenta creada. Revisa tu correo para confirmar tu usuario.');
navigate('/login');
} catch (err) {
toast.error(err.response?.data?.error || 'Error al registrarse');
} finally {
setLoading(false);
}
};
return (
<div className="auth-page">
<div className="auth-card">
<div className="auth-logo">
<PenTool size={32} />
<h1>LinkedSign</h1>
<p>Crear nueva cuenta</p>
</div>
<form onSubmit={handleSubmit} className="auth-form">
<div className="field">
<label>Correo electrónico</label>
<input type="email" name="email" value={form.email} onChange={handleChange} required autoFocus />
</div>
<div className="field">
<label>Contraseña <small>(mínimo 8 caracteres)</small></label>
<input type="password" name="password" value={form.password} onChange={handleChange} required />
<div className="password-rules">
{passwordChecks.map((check) => (
<div
key={check.label}
className={`password-rule ${check.valid ? 'password-rule--ok' : 'password-rule--pending'}`}
>
<span>{check.valid ? '✓' : '•'}</span>
<span>{check.label}</span>
</div>
))}
</div>
</div>
<div className="field">
<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}>
{loading ? 'Registrando...' : 'Crear cuenta'}
</button>
</form>
<p className="auth-link">
¿Ya tienes cuenta? <Link to="/login">Iniciar sesión</Link>
</p>
</div>
</div>
);
}
@@ -0,0 +1,57 @@
import { useEffect, useState } from 'react';
import { Link, useParams } from 'react-router-dom';
import { CheckCircle2, MailWarning, LoaderCircle } from 'lucide-react';
import { verifyEmail } from '../../api/auth';
export default function VerifyEmailPage() {
const { token } = useParams();
const [status, setStatus] = useState('loading');
const [message, setMessage] = useState('Validando enlace de confirmación...');
useEffect(() => {
let active = true;
verifyEmail(token)
.then(({ data }) => {
if (!active) return;
setStatus('success');
setMessage(data.data.message || 'Correo confirmado correctamente.');
})
.catch((error) => {
if (!active) return;
setStatus('error');
setMessage(error.response?.data?.error || 'El enlace no es válido o ya expiró.');
});
return () => {
active = false;
};
}, [token]);
return (
<div className="auth-page">
<div className="auth-card">
<div className="auth-logo">
{status === 'loading' && <LoaderCircle size={32} className="spin-soft" />}
{status === 'success' && <CheckCircle2 size={32} />}
{status === 'error' && <MailWarning size={32} />}
<h1>Confirmación de cuenta</h1>
<p>{message}</p>
</div>
<div className="auth-form">
{status === 'success' && (
<Link to="/login" className="btn btn-primary btn-full">
Ir a iniciar sesión
</Link>
)}
{status === 'error' && (
<Link to="/register" className="btn btn-secondary btn-full">
Volver a registro
</Link>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,210 @@
import { useEffect, useState, useRef } from 'react';
import { listCertificates, uploadCertificate, deleteCertificate } from '../../api/certificates';
import { Shield, Trash2, Upload, RefreshCw, Plus, AlertTriangle, KeyRound } from 'lucide-react';
import toast from 'react-hot-toast';
import PasswordModal from '../../components/ui/PasswordModal';
const formatDate = (d) => d ? new Date(d).toLocaleDateString('es-EC') : '—';
const certStatus = (validTo) => {
if (!validTo) return null;
const now = new Date();
const exp = new Date(validTo);
const daysLeft = Math.floor((exp - now) / (1000 * 60 * 60 * 24));
if (daysLeft < 0) return { label: 'Vencido', cls: 'badge-rejected' };
if (daysLeft <= 30) return { label: `Vence en ${daysLeft}d`, cls: 'badge-pending' };
return { label: 'Vigente', cls: 'badge-signed' };
};
const chainStatusMeta = (chainStatus, certificateCount) => {
if (chainStatus === 'complete') {
return {
label: `Cadena incluida (${certificateCount || 0})`,
text: 'El archivo P12 incluye más de un certificado y aparenta traer cadena embebida.',
};
}
if (chainStatus === 'leaf_only') {
return {
label: 'Sin cadena completa',
text: 'El archivo P12 parece contener solo el certificado del firmante.',
};
}
return null;
};
export default function CertificatesPage() {
const [certs, setCerts] = useState([]);
const [loading, setLoading] = useState(true);
const [showForm, setShowForm] = useState(false);
const [form, setForm] = useState({ alias: '', password: '' });
const [file, setFile] = useState(null);
const [uploading, setUploading] = useState(false);
const [pendingUpload, setPendingUpload] = useState(null); // { file, alias } — waiting for password
const fileInputRef = useRef();
const load = () => {
setLoading(true);
listCertificates()
.then(({ data }) => setCerts(data.data))
.catch(() => toast.error('Error al cargar certificados'))
.finally(() => setLoading(false));
};
useEffect(() => { load(); }, []);
const doUpload = async (uploadFile, password, alias, fromModal = false) => {
setUploading(true);
try {
await uploadCertificate(uploadFile, password, alias || uploadFile.name);
toast.success('Certificado registrado correctamente');
setShowForm(false);
setForm({ alias: '', password: '' });
setFile(null);
setPendingUpload(null);
load();
} catch (err) {
const code = err.response?.data?.code;
// Show popup only if: no password was provided OR we're already in the modal and password was wrong
if (code === 'PASSWORD_REQUIRED' && !password) {
setPendingUpload({ file: uploadFile, alias: alias || uploadFile.name });
} else if (code === 'PASSWORD_REQUIRED' && fromModal) {
toast.error('Contraseña incorrecta. Intenta de nuevo.');
setPendingUpload({ file: uploadFile, alias: alias || uploadFile.name });
} else {
toast.error(err.response?.data?.error || 'Error al subir certificado');
}
} finally {
setUploading(false);
}
};
const handleUpload = async (e) => {
e.preventDefault();
if (!file) { toast.error('Selecciona un archivo P12/PFX'); return; }
await doUpload(file, form.password, form.alias);
};
const handlePasswordModalConfirm = async (password) => {
if (!pendingUpload) return;
await doUpload(pendingUpload.file, password, pendingUpload.alias, true);
};
const handleDelete = async (id, alias) => {
if (!confirm(`¿Eliminar certificado "${alias}"?`)) return;
try {
await deleteCertificate(id);
toast.success('Certificado eliminado');
load();
} catch (err) {
toast.error(err.response?.data?.error || 'Error al eliminar');
}
};
return (
<div className="page">
{pendingUpload && (
<PasswordModal
title="Contraseña del certificado"
description={`El certificado "${pendingUpload.alias}" requiere contraseña para ser validado. No se almacenará — solo se usa en este momento.`}
onConfirm={handlePasswordModalConfirm}
onCancel={() => setPendingUpload(null)}
/>
)}
<div className="page-header">
<div>
<h2>Certificados Digitales</h2>
<p>{certs.length} certificado{certs.length !== 1 ? 's' : ''} registrado{certs.length !== 1 ? 's' : ''}</p>
</div>
<div className="header-actions">
<button className="btn btn-ghost" onClick={load} title="Actualizar"><RefreshCw size={16} /></button>
<button className="btn btn-primary" onClick={() => setShowForm(!showForm)}>
<Plus size={16} /> Agregar certificado
</button>
</div>
</div>
{showForm && (
<div className="form-card">
<h3>Registrar certificado P12/PFX</h3>
<form onSubmit={handleUpload}>
<div className="field">
<label>Archivo P12/PFX</label>
<input type="file" accept=".p12,.pfx" ref={fileInputRef} onChange={(e) => setFile(e.target.files[0] || null)} required />
</div>
<div className="field">
<label>Alias (nombre descriptivo)</label>
<input type="text" value={form.alias} onChange={(e) => setForm(f => ({ ...f, alias: e.target.value }))} placeholder="Ej: Mi certificado BCE 2026" />
</div>
<div className="field">
<label><KeyRound size={13} /> Contraseña <span style={{ fontWeight: 400, color: 'var(--gray-400)' }}>(opcional)</span></label>
<input
type="password"
value={form.password}
onChange={(e) => setForm(f => ({ ...f, password: e.target.value }))}
placeholder="Dejar vacío si no tiene, o si prefieres ingresarla después"
/>
<small className="field-hint">Si el certificado tiene contraseña y no la ingresas aquí, se te solicitará en el momento de la validación.</small>
</div>
<div className="form-actions">
<button type="button" className="btn btn-ghost" onClick={() => setShowForm(false)}>Cancelar</button>
<button type="submit" className="btn btn-primary" disabled={uploading}>
<Upload size={15} /> {uploading ? 'Validando...' : 'Registrar certificado'}
</button>
</div>
</form>
</div>
)}
{loading ? (
<div className="loading-area"><div className="spinner" /></div>
) : certs.length === 0 ? (
<div className="empty-state">
<Shield size={48} />
<p>No tienes certificados. Agrega tu primer certificado P12/PFX vigente.</p>
</div>
) : (
<div className="certs-grid">
{certs.map((cert) => {
const status = certStatus(cert.valid_to);
const chain = chainStatusMeta(cert.chain_status, cert.certificate_count);
return (
<div key={cert.id} className={`cert-card ${!cert.is_active ? 'inactive' : ''}`}>
<div className="cert-header">
<Shield size={20} />
<span className="cert-alias">{cert.alias}</span>
{status && <span className={`badge ${status.cls}`}>{status.label}</span>}
</div>
<div className="cert-details">
{cert.subject_cn && <p><strong>Sujeto:</strong> {cert.subject_cn}</p>}
{cert.issuer_cn && <p><strong>Emisor:</strong> {cert.issuer_cn}</p>}
<p><strong>Válido:</strong> {formatDate(cert.valid_from)} — {formatDate(cert.valid_to)}</p>
{typeof cert.certificate_count === 'number' && <p><strong>Certificados en P12:</strong> {cert.certificate_count}</p>}
{chain && <p><strong>Cadena:</strong> {chain.label}</p>}
</div>
{chain && (
<div className="cert-warning">
<Shield size={13} /> {chain.text}
</div>
)}
{status?.cls === 'badge-rejected' && (
<div className="cert-warning">
<AlertTriangle size={13} /> Este certificado está vencido y no puede usarse para firmar.
</div>
)}
<div className="cert-footer">
<small className="cert-password-note"><KeyRound size={11} /> Se solicitará contraseña al firmar</small>
<button className="btn-icon danger" title="Eliminar" onClick={() => handleDelete(cert.id, cert.alias)}>
<Trash2 size={15} />
</button>
</div>
</div>
);
})}
</div>
)}
</div>
);
}
@@ -0,0 +1,124 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext';
import { listDocuments } from '../../api/documents';
import { FileText, PenTool, CheckCircle2, CircleDot, Search } from 'lucide-react';
export default function DashboardPage() {
const { user } = useAuth();
const navigate = useNavigate();
const [stats, setStats] = useState({ docs: 0, certs: 0, signed: 0, pending: 0, review: 0 });
const [recentDocs, setRecentDocs] = useState([]);
useEffect(() => {
listDocuments({ limit: 100 })
.then((docsRes) => {
const docs = docsRes.data.data.rows || [];
const pending = docs.filter((d) => d.status === 'pending').length;
const review = docs.filter((d) => d.status === 'partially_signed').length;
setStats({
docs: docsRes.data.data.total || 0,
signed: docs.filter((d) => d.status === 'signed').length,
pending,
review,
});
setRecentDocs(docs.slice(0, 5));
})
.catch(() => {});
}, []);
return (
<div className="page">
<div className="step-bar">
<div className="step">
<div className="step-num done"><CheckCircle2 size={10} /></div>
<span className="step-label done-label">Subir documento</span>
</div>
<div className="step-line done-line" />
<div className="step">
<div className="step-num done"><CheckCircle2 size={10} /></div>
<span className="step-label done-label">Revisar contenido</span>
</div>
<div className="step-line done-line" />
<div className="step">
<div className="step-num active">3</div>
<span className="step-label active-label">Firmar</span>
</div>
<div className="step-line" />
<div className="step">
<div className="step-num todo">4</div>
<span className="step-label todo-label">Verificar identidad</span>
</div>
<div className="step-line" />
<div className="step">
<div className="step-num todo">5</div>
<span className="step-label todo-label">Completado</span>
</div>
</div>
<div className="stats-row">
<div className="stat-card" onClick={() => navigate('/documents')}>
<div className="stat-label">Total documentos</div>
<div className="stat-val">{stats.docs}</div>
<span className="stat-badge badge-green">+{Math.max(stats.docs - stats.signed, 0)} este mes</span>
</div>
<div className="stat-card" onClick={() => navigate('/documents')}>
<div className="stat-label">Pendientes de firma</div>
<div className="stat-val">{stats.pending}</div>
<span className="stat-badge badge-orange">Requieren acción</span>
</div>
<div className="stat-card" onClick={() => navigate('/signatures')}>
<div className="stat-label">Firmados</div>
<div className="stat-val">{stats.signed}</div>
<span className="stat-badge badge-green">{stats.docs ? Math.round((stats.signed / stats.docs) * 100) : 0}% tasa de éxito</span>
</div>
<div className="stat-card" onClick={() => navigate('/documents')}>
<div className="stat-label">En revisión</div>
<div className="stat-val">{stats.review}</div>
<span className="stat-badge badge-blue">En proceso</span>
</div>
</div>
<div className="panel">
<div className="panel-header">
<span className="panel-title">Documentos recientes</span>
<span className="panel-action" onClick={() => navigate('/documents')}>Ver todos</span>
</div>
<div className="table-header">
<span className="th">Documento</span>
<span className="th">Firmante</span>
<span className="th">Fecha</span>
<span className="th">Estado</span>
<span className="th"></span>
</div>
{recentDocs.map((doc) => {
const status = doc.status === 'signed'
? { cls: 'pill-signed', label: 'Firmado', icon: <CheckCircle2 size={12} /> }
: doc.status === 'partially_signed'
? { cls: 'pill-review', label: 'Revisión', icon: <Search size={12} /> }
: { cls: 'pill-pending', label: 'Pendiente', icon: <CircleDot size={12} /> };
return (
<div className="table-row" key={doc.id}>
<div className="doc-cell">
<div className="doc-thumb" style={{ background: doc.status === 'signed' ? 'var(--c-success-bg)' : 'var(--c-info-bg)' }}>
<FileText size={13} color={doc.status === 'signed' ? 'var(--c-success)' : 'var(--c-primary)'} />
</div>
<div style={{ minWidth: 0 }}>
<div className="doc-n">{doc.title}</div>
<div className="doc-m">{((doc.file_size || 0) / 1024).toFixed(1)} KB</div>
</div>
</div>
<span className="td">{user?.name || '—'}</span>
<span className="td">{doc.created_at ? new Date(doc.created_at).toLocaleDateString('es-EC') : '—'}</span>
<span className={`status-pill ${status.cls}`}><span className="dot" />{status.label}</span>
<button className="action-ico" type="button" title={doc.status === 'signed' ? 'Ver documento' : 'Firmar'} onClick={() => navigate(doc.status === 'signed' ? '/documents' : `/signatures?docId=${doc.id}`)}>
{doc.status === 'signed' ? <Search size={13} /> : <PenTool size={13} />}
</button>
</div>
);
})}
</div>
</div>
);
}
@@ -0,0 +1,276 @@
import { useEffect, useState, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { listDocuments, uploadDocument, deleteDocument, downloadDocument } from '../../api/documents';
import { listSignedDocuments, downloadSigned } from '../../api/signatures';
import { Upload, Trash2, Download, PenTool, FileText, RefreshCw, Eye, X } from 'lucide-react';
import { Document, Page, pdfjs } from 'react-pdf';
import 'react-pdf/dist/Page/AnnotationLayer.css';
import 'react-pdf/dist/Page/TextLayer.css';
import toast from 'react-hot-toast';
pdfjs.GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/build/pdf.worker.min.mjs',
import.meta.url,
).toString();
const STATUS_LABELS = {
pending: { label: 'Pendiente', cls: 'badge-pending' },
signed: { label: 'Firmado', cls: 'badge-signed' },
partially_signed: { label: 'Parcial', cls: 'badge-partial' },
rejected: { label: 'Rechazado', cls: 'badge-rejected' },
};
export default function DocumentsPage() {
const navigate = useNavigate();
const [docs, setDocs] = useState([]);
const [signedDocs, setSignedDocs] = useState([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [uploading, setUploading] = useState(false);
const fileInputRef = useRef();
// Viewer state
const [viewerBlob, setViewerBlob] = useState(null);
const [viewerTitle, setViewerTitle] = useState('');
const [numPages, setNumPages] = useState(null);
const [currentPage, setCurrentPage] = useState(1);
const [loadingViewer, setLoadingViewer] = useState(false);
const loadDocs = () => {
setLoading(true);
Promise.all([
listDocuments({ limit: 50 }),
listSignedDocuments(),
])
.then(([documentsResponse, signedResponse]) => {
setDocs(documentsResponse.data.data.rows);
setTotal(documentsResponse.data.data.total);
setSignedDocs(signedResponse.data.data || []);
})
.catch(() => toast.error('Error al cargar documentos'))
.finally(() => setLoading(false));
};
useEffect(() => { loadDocs(); }, []);
const handleUpload = async (e) => {
const file = e.target.files[0];
if (!file) return;
if (file.type !== 'application/pdf') { toast.error('Solo se permiten archivos PDF'); return; }
setUploading(true);
try {
await uploadDocument(file);
toast.success('Documento subido correctamente');
loadDocs();
} catch (err) {
toast.error(err.response?.data?.error || 'Error al subir documento');
} finally {
setUploading(false);
fileInputRef.current.value = '';
}
};
const handleDelete = async (id, title) => {
if (!confirm(`¿Eliminar "${title}"?`)) return;
try {
await deleteDocument(id);
toast.success('Documento eliminado');
loadDocs();
} catch (err) {
toast.error(err.response?.data?.error || 'Error al eliminar');
}
};
const handleDownload = async (id, filename) => {
try {
const { data } = await downloadDocument(id);
const url = URL.createObjectURL(data);
const a = document.createElement('a'); a.href = url; a.download = filename; a.click();
URL.revokeObjectURL(url);
} catch { toast.error('Error al descargar'); }
};
const handleDownloadSigned = async (id, filename) => {
try {
const { data } = await downloadSigned(id);
const url = URL.createObjectURL(data);
const a = document.createElement('a'); a.href = url; a.download = filename; a.click();
URL.revokeObjectURL(url);
} catch { toast.error('Error al descargar PDF firmado'); }
};
const handleView = async (id, title, source = 'original') => {
setLoadingViewer(true);
setViewerTitle(title);
setViewerBlob(null);
setCurrentPage(1);
setNumPages(null);
try {
const request = source === 'signed' ? downloadSigned(id) : downloadDocument(id);
const { data } = await request;
setViewerBlob(data);
} catch { toast.error('No se pudo cargar el documento'); }
finally { setLoadingViewer(false); }
};
const closeViewer = () => { setViewerBlob(null); setViewerTitle(''); };
return (
<div className="page">
{/* ── PDF Viewer Modal ── */}
{(viewerBlob || loadingViewer) && (
<div className="modal-overlay" onClick={closeViewer}>
<div className="pdf-viewer-modal" onClick={e => e.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(p => p - 1)}>‹</button>
<span>{currentPage} / {numPages}</span>
<button className="btn btn-ghost" disabled={currentPage >= numPages}
onClick={() => setCurrentPage(p => p + 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 }) => setNumPages(numPages)}
loading={<div className="loading-area"><div className="spinner" /></div>}
>
<Page pageNumber={currentPage} width={720} />
</Document>
)}
</div>
</div>
</div>
)}
<div className="page-header">
<div>
<h2>Documentos</h2>
<p>{total} documento{total !== 1 ? 's' : ''} original{total !== 1 ? 'es' : ''}</p>
</div>
<div className="header-actions">
<button className="btn btn-ghost" onClick={loadDocs} title="Actualizar"><RefreshCw size={16} /></button>
<button className="btn btn-primary" onClick={() => fileInputRef.current?.click()} disabled={uploading}>
<Upload size={16} /> {uploading ? 'Subiendo...' : 'Subir PDF'}
</button>
<input ref={fileInputRef} type="file" accept=".pdf,application/pdf" style={{ display: 'none' }} onChange={handleUpload} />
</div>
</div>
{loading ? (
<div className="loading-area"><div className="spinner" /></div>
) : (
<div className="documents-sections">
<section className="documents-section">
<div className="section-header">
<h3>Documentos originales para firmar</h3>
</div>
{docs.length === 0 ? (
<div className="empty-state compact">
<FileText size={40} />
<p>No tienes documentos originales aún. Sube tu primer PDF.</p>
</div>
) : (
<div className="table-wrapper">
<table className="data-table">
<thead>
<tr>
<th>Nombre</th>
<th>Tamaño</th>
<th>Estado</th>
<th>Fecha</th>
<th>Acciones</th>
</tr>
</thead>
<tbody>
{docs.map((doc) => {
const s = STATUS_LABELS[doc.status] || STATUS_LABELS.pending;
return (
<tr key={doc.id}>
<td className="doc-name"><FileText size={14} /> {doc.title}</td>
<td>{(doc.file_size / 1024).toFixed(1)} KB</td>
<td><span className={`badge ${s.cls}`}>{s.label}</span></td>
<td>{new Date(doc.created_at).toLocaleDateString('es-EC')}</td>
<td className="actions-cell">
<button className="btn-icon" title="Ver documento" onClick={() => handleView(doc.id, doc.title, 'original')}>
<Eye size={15} />
</button>
<button className="btn-icon" title="Firmar" onClick={() => navigate(`/signatures?docId=${doc.id}`)}>
<PenTool size={15} />
</button>
<button className="btn-icon" title="Descargar" onClick={() => handleDownload(doc.id, doc.filename)}>
<Download size={15} />
</button>
<button className="btn-icon danger" title="Eliminar" onClick={() => handleDelete(doc.id, doc.title)}>
<Trash2 size={15} />
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</section>
<section className="documents-section">
<div className="section-header">
<h3>Documentos firmados generados</h3>
<p>{signedDocs.length} archivo{signedDocs.length !== 1 ? 's' : ''} firmado{signedDocs.length !== 1 ? 's' : ''}</p>
</div>
{signedDocs.length === 0 ? (
<div className="empty-state compact">
<FileText size={40} />
<p>Aún no has generado documentos firmados.</p>
</div>
) : (
<div className="table-wrapper">
<table className="data-table">
<thead>
<tr>
<th>PDF firmado</th>
<th>Documento origen</th>
<th>Certificado</th>
<th>Fecha</th>
<th>Acciones</th>
</tr>
</thead>
<tbody>
{signedDocs.map((doc) => (
<tr key={doc.id}>
<td className="doc-name"><FileText size={14} /> {doc.signed_filename}</td>
<td>{doc.document_title}</td>
<td>{doc.certificate_alias}</td>
<td>{new Date(doc.signed_at).toLocaleDateString('es-EC')}</td>
<td className="actions-cell">
<button className="btn-icon" title="Ver PDF firmado" onClick={() => handleView(doc.id, doc.signed_filename, 'signed')}>
<Eye size={15} />
</button>
<button className="btn-icon" title="Descargar PDF firmado" onClick={() => handleDownloadSigned(doc.id, doc.signed_filename)}>
<Download size={15} />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
</div>
)}
</div>
);
}
@@ -0,0 +1,76 @@
import { useEffect, useState } from 'react';
import { Save, Palette } from 'lucide-react';
import toast from 'react-hot-toast';
import SignatureStylePicker from '../../components/ui/SignatureStylePicker';
import { useAuth } from '../../contexts/AuthContext';
export default function SignatureStylePage() {
const { user, updateSignatureAppearancePreference } = useAuth();
const [form, setForm] = useState({
signatureStyle: user?.signaturePreferences?.signatureStyle || 'qr',
qrPosition: user?.signaturePreferences?.qrPosition || 'left',
qrBackground: user?.signaturePreferences?.qrBackground || 'transparent',
qrBorder: user?.signaturePreferences?.qrBorder || 'none',
signatureImageBase64: user?.signaturePreferences?.signatureImageBase64 || null,
});
const [saving, setSaving] = useState(false);
useEffect(() => {
setForm({
signatureStyle: user?.signaturePreferences?.signatureStyle || 'qr',
qrPosition: user?.signaturePreferences?.qrPosition || 'left',
qrBackground: user?.signaturePreferences?.qrBackground || 'transparent',
qrBorder: user?.signaturePreferences?.qrBorder || 'none',
signatureImageBase64: user?.signaturePreferences?.signatureImageBase64 || null,
});
}, [user?.signaturePreferences]);
const handleSave = async () => {
setSaving(true);
try {
await updateSignatureAppearancePreference(form);
toast.success('Estilo de firma guardado');
} catch {
toast.error('No se pudo guardar el estilo de firma');
} finally {
setSaving(false);
}
};
return (
<div className="page">
<div className="page-header">
<div>
<h2>Estilo Firma</h2>
<p>Configura cómo se verá tu firma visible en los documentos.</p>
</div>
<div className="header-actions">
<button className="btn btn-primary" onClick={handleSave} disabled={saving}>
<Save size={16} /> {saving ? 'Guardando...' : 'Guardar estilo'}
</button>
</div>
</div>
<div className="form-card">
<div className="style-page-intro">
<Palette size={18} />
<p>Este menú define el estilo visual que usará la pantalla Firmar por defecto.</p>
</div>
<SignatureStylePicker
value={form.signatureStyle}
onChange={(signatureStyle) => setForm((currentForm) => ({ ...currentForm, signatureStyle }))}
qrPosition={form.qrPosition}
onQrPositionChange={(qrPosition) => setForm((currentForm) => ({ ...currentForm, qrPosition }))}
qrBackground={form.qrBackground}
onQrBackgroundChange={(qrBackground) => setForm((currentForm) => ({ ...currentForm, qrBackground }))}
qrBorder={form.qrBorder}
onQrBorderChange={(qrBorder) => setForm((currentForm) => ({ ...currentForm, qrBorder }))}
signatureImage={form.signatureImageBase64}
onImageChange={(signatureImageBase64) => setForm((currentForm) => ({ ...currentForm, signatureImageBase64 }))}
signerName={user?.name || 'Firmante'}
/>
</div>
</div>
);
}
@@ -0,0 +1,300 @@
import { useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { listDocuments, downloadDocument } from '../../api/documents';
import { listCertificates } from '../../api/certificates';
import { signDocument, getSignatures, downloadSigned } from '../../api/signatures';
import { PenTool, FileText, Shield, CheckCircle, MapPin, Download } from 'lucide-react';
import toast from 'react-hot-toast';
import PasswordModal from '../../components/ui/PasswordModal';
import PdfSignaturePlacer from '../../components/ui/PdfSignaturePlacer';
import { useAuth } from '../../contexts/AuthContext';
const STEPS = ['Selección', 'Posición', 'Firmar'];
export default function SignaturesPage() {
const { user } = useAuth();
const [searchParams] = useSearchParams();
const [docs, setDocs] = useState([]);
const [certs, setCerts] = useState([]);
const [form, setForm] = useState({
documentId: searchParams.get('docId') || '',
certificateId: '',
reason: 'Firmado digitalmente',
location: '',
signatureStyle: user?.signaturePreferences?.signatureStyle || 'qr',
qrPosition: user?.signaturePreferences?.qrPosition || 'left',
qrBackground: user?.signaturePreferences?.qrBackground || 'transparent',
qrBorder: user?.signaturePreferences?.qrBorder || 'none',
signatureImageBase64: user?.signaturePreferences?.signatureImageBase64 || null,
});
const [step, setStep] = useState(0); // 0=select, 1=place, 2=sign
const [pdfBlob, setPdfBlob] = useState(null);
const [sigPlacement, setSigPlacement] = useState(null); // { page, rect }
const [signing, setSigning] = useState(false);
const [result, setResult] = useState(null);
const [sigs, setSigs] = useState([]);
const [pendingSign, setPendingSign] = useState(null);
useEffect(() => {
Promise.all([
listDocuments({ limit: 100 }).then(({ data }) => setDocs(data.data.rows.filter(d => d.status !== 'signed'))),
listCertificates().then(({ data }) => setCerts(data.data.filter(c => c.is_active))),
]).catch(() => toast.error('Error al cargar datos'));
}, []);
useEffect(() => {
if (form.documentId) {
getSignatures(form.documentId).then(({ data }) => setSigs(data.data || [])).catch(() => setSigs([]));
}
}, [form.documentId]);
useEffect(() => {
if (!user?.signaturePreferences) return;
setForm((currentForm) => ({
...currentForm,
signatureStyle: user.signaturePreferences.signatureStyle || 'qr',
qrPosition: user.signaturePreferences.qrPosition || 'left',
qrBackground: user.signaturePreferences.qrBackground || 'transparent',
qrBorder: user.signaturePreferences.qrBorder || 'none',
signatureImageBase64: user.signaturePreferences.signatureImageBase64 || null,
}));
}, [user?.signaturePreferences]);
const handleChange = (e) => setForm(f => ({ ...f, [e.target.name]: e.target.value }));
// Step 0 → 1: load PDF for placement
const handleGoToPlace = async () => {
if (!form.documentId || !form.certificateId) {
toast.error('Selecciona documento y certificado'); return;
}
try {
const { data } = await downloadDocument(form.documentId);
setPdfBlob(data);
setSigPlacement(null);
setStep(1);
} catch { toast.error('No se pudo cargar el documento'); }
};
// Step 1 → 2: placement confirmed
const handlePlaced = (placement) => {
setSigPlacement(placement);
setStep(2);
};
const doSign = async (certPassword = '', fromModal = false) => {
if (!form.documentId || !form.certificateId) {
toast.error('Faltan datos de documento o certificado. Vuelve al paso 1.');
setStep(0);
return;
}
setSigning(true);
const payload = {
documentId: Number(form.documentId),
certificateId: Number(form.certificateId),
certPassword,
reason: form.reason,
location: form.location,
page: sigPlacement?.page ?? 0,
rect: sigPlacement?.rect ?? null,
signatureStyle: form.signatureStyle,
qrPosition: form.qrPosition,
qrBackground: form.qrBackground,
qrBorder: form.qrBorder,
signatureImageBase64: form.signatureImageBase64,
};
try {
const { data } = await signDocument(payload);
setResult(data.data);
toast.success('¡Documento firmado exitosamente!');
setDocs(prev => prev.filter(d => d.id !== payload.documentId));
setForm(f => ({ ...f, documentId: '', certificateId: '' }));
setSigPlacement(null);
setPdfBlob(null);
setStep(0);
setPendingSign(null);
} catch (err) {
const code = err.response?.data?.code;
if (code === 'CERT_PASSWORD_REQUIRED' && !fromModal) {
setPendingSign(payload);
} else if (code === 'CERT_PASSWORD_REQUIRED' && fromModal) {
toast.error('Contraseña incorrecta. Intenta de nuevo.');
setPendingSign(payload);
} else {
toast.error(err.response?.data?.error || 'Error al firmar');
}
} finally { setSigning(false); }
};
const selectedCert = certs.find(c => c.id === Number(form.certificateId));
const selectedDoc = docs.find(d => d.id === Number(form.documentId));
return (
<div className="page">
{pendingSign && (
<PasswordModal
title="Contraseña del certificado"
description={`El certificado "${selectedCert?.alias || ''}" requiere contraseña para firmar. No se almacenará.`}
onConfirm={(pw) => doSign(pw, true)}
onCancel={() => setPendingSign(null)}
/>
)}
<div className="page-header">
<h2>Firmar Documento</h2>
<p>Firma digital con certificado P12 — firma visible en el PDF</p>
</div>
{/* Steps indicator */}
<div className="steps-bar">
{STEPS.map((label, i) => (
<div key={i} className={`step ${i === step ? 'active' : ''} ${i < step ? 'done' : ''}`}>
<div className="step-dot">{i < step ? '✓' : i + 1}</div>
<span>{label}</span>
</div>
))}
</div>
{/* ── Step 0: Selección ── */}
{step === 0 && (
<div className="sign-layout">
<div className="sign-form-card">
<div className="field">
<label><FileText size={14} /> Documento</label>
<select name="documentId" value={form.documentId} onChange={handleChange} required>
<option value="">Selecciona un documento...</option>
{docs.map(d => <option key={d.id} value={d.id}>{d.title}</option>)}
</select>
{docs.length === 0 && <small className="field-hint">No hay documentos pendientes.</small>}
</div>
<div className="field">
<label><Shield size={14} /> Certificado digital</label>
<select name="certificateId" value={form.certificateId} onChange={handleChange} required>
<option value="">Selecciona un certificado...</option>
{certs.map(c => <option key={c.id} value={c.id}>{c.alias}{c.subject_cn ? ` (${c.subject_cn})` : ''}</option>)}
</select>
{certs.length === 0 && <small className="field-hint">No tienes certificados activos.</small>}
</div>
<div className="field">
<label>Razón de firma</label>
<input type="text" name="reason" value={form.reason} onChange={handleChange} />
</div>
<div className="field">
<label>Ubicación</label>
<input type="text" name="location" value={form.location} onChange={handleChange} placeholder="Ciudad, País..." />
</div>
<div className="info-box">
<h4>Estilo aplicado</h4>
<p>La firma usará tu configuración guardada en el menú <strong>Estilo Firma</strong>.</p>
<p><strong>Tipo:</strong> {form.signatureStyle}</p>
{form.signatureStyle === 'qr' && <p><strong>Posición QR:</strong> {form.qrPosition === 'left' ? 'Izquierda' : 'Derecha'}</p>}
{form.signatureStyle === 'qr' && <p><strong>Fondo:</strong> {form.qrBackground === 'transparent' ? 'Transparente' : 'Suave'}</p>}
{form.signatureStyle === 'qr' && <p><strong>Borde:</strong> {form.qrBorder === 'none' ? 'Sin borde' : 'Con borde'}</p>}
</div>
<button className="btn btn-primary btn-full" onClick={handleGoToPlace}>
<MapPin size={16} /> Elegir posición de firma →
</button>
</div>
<div className="sign-info-panel">
{result && (
<div className="result-card success">
<CheckCircle size={32} />
<h3>¡Firma aplicada!</h3>
<p>Firma visible + firma criptográfica válida.</p>
<code>{result.signedFilename}</code>
<button
className="btn btn-primary"
style={{ marginTop: 12, width: '100%', justifyContent: 'center' }}
onClick={async () => {
try {
const { data } = await downloadSigned(result.sigId);
const url = URL.createObjectURL(data);
const a = document.createElement('a');
a.href = url; a.download = result.signedFilename; a.click();
URL.revokeObjectURL(url);
} catch { toast.error('Error al descargar'); }
}}
>
<Download size={15} /> Descargar PDF firmado
</button>
</div>
)}
{sigs.length > 0 && (
<div className="sigs-list">
<h3>Firmas del documento</h3>
{sigs.map(sig => (
<div key={sig.id} className="sig-item">
<CheckCircle size={16} className="sig-icon" />
<div>
<strong>{sig.signer_name}</strong>
<p>{sig.signer_email}</p>
<small>{new Date(sig.signed_at).toLocaleString('es-EC')}</small>
</div>
</div>
))}
</div>
)}
<div className="info-box">
<h4>Proceso de firma</h4>
<ol>
<li>Selecciona documento y certificado</li>
<li>Elige dónde colocar la firma en el PDF</li>
<li>Confirma — se pedirá contraseña si es necesario</li>
<li>Descarga el PDF firmado con firma visible</li>
</ol>
</div>
</div>
</div>
)}
{/* ── Step 1: Posicionamiento ── */}
{step === 1 && pdfBlob && (
<div>
<div className="step-nav">
<button className="btn btn-ghost" onClick={() => setStep(0)}>← Volver</button>
<p className="step-desc">
Haz clic donde quieres que aparezca la firma en <strong>{selectedDoc?.title}</strong>
</p>
</div>
<PdfSignaturePlacer
pdfBlob={pdfBlob}
signerName={selectedCert?.subject_cn || user?.name || 'Firmante'}
signatureStyle={form.signatureStyle}
qrPosition={form.qrPosition}
qrBackground={form.qrBackground}
qrBorder={form.qrBorder}
signatureImage={form.signatureImageBase64}
onPlaced={handlePlaced}
/>
</div>
)}
{/* ── Step 2: Confirmar y firmar ── */}
{step === 2 && (
<div className="sign-confirm-card">
<div className="sign-confirm-header">
<CheckCircle size={24} color="var(--success)" />
<h3>Posición confirmada — listo para firmar</h3>
</div>
<div className="sign-confirm-details">
<p><strong>Documento:</strong> {selectedDoc?.title}</p>
<p><strong>Certificado:</strong> {selectedCert?.alias}</p>
<p><strong>Razón:</strong> {form.reason}</p>
{form.location && <p><strong>Ubicación:</strong> {form.location}</p>}
<p><strong>Estilo visual:</strong> {form.signatureStyle}</p>
{form.signatureStyle === 'qr' && <p><strong>Posición QR:</strong> {form.qrPosition === 'left' ? 'Izquierda' : 'Derecha'}</p>}
{form.signatureStyle === 'qr' && <p><strong>Fondo:</strong> {form.qrBackground === 'transparent' ? 'Transparente' : 'Suave'}</p>}
{form.signatureStyle === 'qr' && <p><strong>Borde:</strong> {form.qrBorder === 'none' ? 'Sin borde' : 'Con borde'}</p>}
<p><strong>Página:</strong> {(sigPlacement?.page ?? 0) + 1}</p>
</div>
<div className="sign-confirm-actions">
<button className="btn btn-ghost" onClick={() => setStep(1)}>← Cambiar posición</button>
<button className="btn btn-primary" onClick={() => doSign('')} disabled={signing}>
<PenTool size={16} /> {signing ? 'Firmando...' : 'Firmar documento'}
</button>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,98 @@
import { useEffect, useState } from 'react';
import { useParams, Link } from 'react-router-dom';
import { ShieldCheck, ShieldAlert, FileText, UserRound, CalendarDays, MapPin } from 'lucide-react';
import { verifySignature } from '../../api/public';
export default function VerifySignaturePage() {
const { code } = useParams();
const [loading, setLoading] = useState(true);
const [result, setResult] = useState(null);
const [error, setError] = useState(null);
useEffect(() => {
setLoading(true);
verifySignature(code)
.then(({ data }) => {
setResult(data.data);
setError(null);
})
.catch((err) => {
setError(err.response?.data?.error || 'No se pudo verificar la firma.');
setResult(null);
})
.finally(() => setLoading(false));
}, [code]);
return (
<div className="verify-page">
<div className="verify-card">
{loading ? (
<div className="loading-area"><div className="spinner" /></div>
) : error ? (
<>
<div className="verify-status verify-status--invalid">
<ShieldAlert size={28} />
<div>
<h1>Verificación no disponible</h1>
<p>{error}</p>
</div>
</div>
<Link to="/login" className="btn btn-ghost">Volver</Link>
</>
) : (
<>
<div className={`verify-status ${result.isValid ? 'verify-status--valid' : 'verify-status--invalid'}`}>
{result.isValid ? <ShieldCheck size={28} /> : <ShieldAlert size={28} />}
<div>
<h1>{result.isValid ? 'Firma electrónica válida' : 'Firma electrónica no válida'}</h1>
<p>
{result.isValid
? 'La integridad del documento coincide con el registro de LinkedSign.'
: 'La integridad del documento no coincide con el registro de LinkedSign.'}
</p>
</div>
</div>
<div className="verify-grid">
<div className="verify-item">
<FileText size={16} />
<div>
<strong>Documento</strong>
<span>{result.documentTitle}</span>
</div>
</div>
<div className="verify-item">
<UserRound size={16} />
<div>
<strong>Firmante</strong>
<span>{result.certificateSubjectCn || result.signerName}</span>
</div>
</div>
<div className="verify-item">
<CalendarDays size={16} />
<div>
<strong>Fecha de firma</strong>
<span>{new Date(result.signedAt).toLocaleString('es-EC')}</span>
</div>
</div>
<div className="verify-item">
<MapPin size={16} />
<div>
<strong>Ubicación</strong>
<span>{result.location || '—'}</span>
</div>
</div>
</div>
<div className="verify-details">
<p><strong>Motivo:</strong> {result.reason || 'Firmado digitalmente'}</p>
<p><strong>Emisor del certificado:</strong> {result.certificateIssuerCn || '—'}</p>
<p><strong>Código de verificación:</strong> {result.verificationCode}</p>
<p><strong>Archivo firmado:</strong> {result.signedFilename}</p>
</div>
</>
)}
</div>
</div>
);
}