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,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>
);
}