Initial LinkedSign platform
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user