Files
linkedsign/frontend/src/pages/signatures/SignaturesPage.jsx
T
2026-04-06 20:34:18 -05:00

301 lines
13 KiB
React

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