Agregar firmas externas seguras y mejorar entregabilidad de correos

This commit is contained in:
2026-04-08 22:58:24 -05:00
parent 46ec312706
commit 1e965b6e71
19 changed files with 2185 additions and 80 deletions
+272 -1
View File
@@ -2,7 +2,8 @@ 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 { addInvitationFlowInvitees, createInvitationFlow, deleteInvitation, getInvitationFlow } from '../../api/invitations';
import { Upload, Trash2, Download, PenTool, FileText, RefreshCw, Eye, X, Users, Mail, ShieldAlert, Route } from 'lucide-react';
import { Document, Page, pdfjs } from 'react-pdf';
import 'react-pdf/dist/Page/AnnotationLayer.css';
import 'react-pdf/dist/Page/TextLayer.css';
@@ -39,6 +40,13 @@ export default function DocumentsPage() {
const [numPages, setNumPages] = useState(null);
const [currentPage, setCurrentPage] = useState(1);
const [loadingViewer, setLoadingViewer] = useState(false);
const [invitationModalOpen, setInvitationModalOpen] = useState(false);
const [selectedSignedDoc, setSelectedSignedDoc] = useState(null);
const [flowLoading, setFlowLoading] = useState(false);
const [flowData, setFlowData] = useState(null);
const [invitees, setInvitees] = useState([{ name: '', email: '' }]);
const [creatingFlow, setCreatingFlow] = useState(false);
const [deletingInvitationId, setDeletingInvitationId] = useState(null);
const loadDocs = () => {
setLoading(true);
@@ -127,6 +135,99 @@ export default function DocumentsPage() {
const closeViewer = () => { setViewerBlob(null); setViewerTitle(''); };
const closeInvitationModal = () => {
setInvitationModalOpen(false);
setSelectedSignedDoc(null);
setFlowData(null);
setInvitees([{ name: '', email: '' }]);
setCreatingFlow(false);
};
const openInvitationModal = async (signedDoc) => {
setSelectedSignedDoc(signedDoc);
setInvitationModalOpen(true);
setFlowLoading(true);
setFlowData(null);
try {
const { data } = await getInvitationFlow(signedDoc.document_id);
setFlowData(data.data);
} catch (error) {
toast.error(error.response?.data?.error || 'No se pudo cargar la cadena de firmas');
} finally {
setFlowLoading(false);
}
};
const updateInvitee = (index, field, value) => {
setInvitees((current) => current.map((invitee, inviteeIndex) => (
inviteeIndex === index ? { ...invitee, [field]: value } : invitee
)));
};
const addInvitee = () => {
if (invitees.length >= 5) {
toast.error('Solo puedes invitar hasta 5 personas');
return;
}
setInvitees((current) => [...current, { name: '', email: '' }]);
};
const removeInvitee = (index) => {
setInvitees((current) => current.filter((_, inviteeIndex) => inviteeIndex !== index));
};
const handleCreateFlow = async (event) => {
event.preventDefault();
if (!selectedSignedDoc) return;
const cleanedInvitees = invitees
.map((invitee) => ({ name: invitee.name.trim(), email: invitee.email.trim().toLowerCase() }))
.filter((invitee) => invitee.name && invitee.email);
if (cleanedInvitees.length === 0) {
toast.error('Agrega al menos un firmante externo');
return;
}
setCreatingFlow(true);
try {
const request = flowData
? addInvitationFlowInvitees(selectedSignedDoc.document_id, { invitees: cleanedInvitees })
: createInvitationFlow({
documentId: selectedSignedDoc.document_id,
invitees: cleanedInvitees,
});
const { data } = await request;
setFlowData(data.data);
setInvitees([{ name: '', email: '' }]);
toast.success('Flujo de firmas externas creado');
} catch (error) {
toast.error(error.response?.data?.error || 'No se pudo crear la cadena de firmas');
} finally {
setCreatingFlow(false);
}
};
const handleDeleteInvitation = async (invitee) => {
if (!confirm(`¿Eliminar la invitación de ${invitee.invitee_name || invitee.inviteeName}?`)) return;
setDeletingInvitationId(invitee.id);
try {
const { data } = await deleteInvitation(invitee.id);
setFlowData(data.data);
if ((data.data?.invitees || []).length < 5) {
setInvitees((current) => current.length > 0 ? current : [{ name: '', email: '' }]);
}
toast.success('Invitación eliminada');
} catch (error) {
toast.error(error.response?.data?.error || 'No se pudo eliminar la invitación');
} finally {
setDeletingInvitationId(null);
}
};
const canAddMoreInvitees = (flowData?.invitees?.length || 0) < 5;
return (
<div className="page">
@@ -166,6 +267,173 @@ export default function DocumentsPage() {
</div>
)}
{invitationModalOpen && (
<div className="modal-overlay" onClick={closeInvitationModal}>
<div className="pdf-viewer-modal invitation-modal" onClick={(event) => event.stopPropagation()}>
<div className="pdf-viewer-header">
<span className="pdf-viewer-title"><Users size={16} /> Firmas externas</span>
<div className="pdf-viewer-controls">
<button className="btn-icon" type="button" onClick={closeInvitationModal}><X size={18} /></button>
</div>
</div>
<div className="pdf-viewer-body invitation-modal-body">
{flowLoading ? (
<div className="loading-area"><div className="spinner" /></div>
) : (
<div className="documents-sections">
<div className="form-card invitation-owner-card">
<h3>{selectedSignedDoc?.document_title || selectedSignedDoc?.signed_filename}</h3>
<div className="invitation-owner-warnings">
<div className="invitation-info-line">
<ShieldAlert size={16} />
<span>Solo puedes invitar personas que dispongan de firma electrónica P12/PFX.</span>
</div>
<div className="invitation-info-line">
<Route size={16} />
<span>El sistema enviará automáticamente cada solicitud según el orden definido.</span>
</div>
</div>
</div>
{flowData ? (
<>
{canAddMoreInvitees && (
<div className="form-card">
<h3>Agregar más firmantes externos</h3>
<form onSubmit={handleCreateFlow}>
<div className="invitation-draft-list">
{invitees.map((invitee, index) => (
<div key={`invitee-${index}`} className="invitation-draft-row">
<div className="form-group">
<label>Nombre del firmante nuevo {index + 1}</label>
<input
type="text"
value={invitee.name}
onChange={(event) => updateInvitee(index, 'name', event.target.value)}
placeholder="Nombre completo"
required
/>
</div>
<div className="form-group">
<label>Correo del firmante nuevo {index + 1}</label>
<input
type="email"
value={invitee.email}
onChange={(event) => updateInvitee(index, 'email', event.target.value)}
placeholder="correo@dominio.com"
required
/>
</div>
{invitees.length > 1 && (
<button
className="btn btn-ghost invitation-remove-btn"
type="button"
onClick={() => removeInvitee(index)}
>
Quitar
</button>
)}
</div>
))}
</div>
<div className="modal-actions invitation-owner-actions">
<button className="btn btn-secondary" type="button" onClick={addInvitee}>
Agregar firmante
</button>
<button className="btn btn-primary" type="submit" disabled={creatingFlow}>
{creatingFlow ? 'Agregando...' : 'Guardar nuevos firmantes'}
</button>
</div>
</form>
</div>
)}
<div className="form-card">
<h3>Orden actual de firmantes</h3>
<div className="invitation-order-list">
{flowData.invitees?.map((invitee) => (
<div key={invitee.id} className="invitation-order-row">
<div>
<strong>{invitee.order_index || invitee.orderIndex}. {invitee.invitee_name || invitee.inviteeName}</strong>
<span>{invitee.invitee_email || invitee.inviteeEmail}</span>
</div>
<div className="invitation-order-actions">
<span className={`badge invitation-status-badge invitation-status-${invitee.status}`}>
{invitee.status === 'queued' ? 'En espera' : invitee.status === 'sent' ? 'Enviado' : invitee.status === 'opened' ? 'Abierto' : invitee.status === 'signed' ? 'Firmado' : invitee.status}
</span>
{invitee.status !== 'signed' && (
<button
className="btn btn-ghost invitation-inline-delete"
type="button"
disabled={deletingInvitationId === invitee.id}
onClick={() => handleDeleteInvitation(invitee)}
>
{deletingInvitationId === invitee.id ? 'Eliminando...' : 'Eliminar'}
</button>
)}
</div>
</div>
))}
</div>
</div>
</>
) : (
<div className="form-card">
<h3>Configurar firmantes externos</h3>
<form onSubmit={handleCreateFlow}>
<div className="invitation-draft-list">
{invitees.map((invitee, index) => (
<div key={`invitee-${index}`} className="invitation-draft-row">
<div className="form-group">
<label>Nombre del firmante {index + 1}</label>
<input
type="text"
value={invitee.name}
onChange={(event) => updateInvitee(index, 'name', event.target.value)}
placeholder="Nombre completo"
required
/>
</div>
<div className="form-group">
<label>Correo del firmante {index + 1}</label>
<input
type="email"
value={invitee.email}
onChange={(event) => updateInvitee(index, 'email', event.target.value)}
placeholder="correo@dominio.com"
required
/>
</div>
{invitees.length > 1 && (
<button
className="btn btn-ghost invitation-remove-btn"
type="button"
onClick={() => removeInvitee(index)}
>
Quitar
</button>
)}
</div>
))}
</div>
<div className="modal-actions invitation-owner-actions">
<button className="btn btn-secondary" type="button" onClick={addInvitee}>
Agregar firmante
</button>
<button className="btn btn-primary" type="submit" disabled={creatingFlow}>
{creatingFlow ? 'Creando...' : 'Enviar primera invitación'}
</button>
</div>
</form>
</div>
)}
</div>
)}
</div>
</div>
</div>
)}
<div className="page-header">
<div>
<h2>Documentos</h2>
@@ -291,6 +559,9 @@ export default function DocumentsPage() {
<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="Solicitar firmas externas" onClick={() => openInvitationModal(doc)}>
<Users size={15} />
</button>
<button className="btn-icon" title="Descargar PDF firmado" onClick={() => handleDownloadSigned(doc.id, doc.signed_filename)}>
<Download size={15} />
</button>
@@ -0,0 +1,282 @@
import { useEffect, useMemo, useState } from 'react';
import { useParams } from 'react-router-dom';
import toast from 'react-hot-toast';
import { ShieldCheck, FileText, Upload, LockKeyhole, AlertTriangle, MailCheck } from 'lucide-react';
import PdfSignaturePlacer from '../../components/ui/PdfSignaturePlacer';
import {
acceptPublicInvitationTerms,
downloadPublicInvitationDocument,
getPublicInvitation,
signPublicInvitation,
} from '../../api/invitations';
const formatExpiry = (value) => {
if (!value) return '';
try {
return new Date(value).toLocaleString('es-EC');
} catch {
return value;
}
};
export default function GuestInvitationPage() {
const { token } = useParams();
const [loading, setLoading] = useState(true);
const [invitation, setInvitation] = useState(null);
const [pdfBlob, setPdfBlob] = useState(null);
const [showTermsModal, setShowTermsModal] = useState(false);
const [position, setPosition] = useState(null);
const [certificateFile, setCertificateFile] = useState(null);
const [certPassword, setCertPassword] = useState('');
const [reason, setReason] = useState('Firmado por invitación segura');
const [location, setLocation] = useState('');
const [submitting, setSubmitting] = useState(false);
const [completedState, setCompletedState] = useState(null);
useEffect(() => {
const load = async () => {
setLoading(true);
try {
const [{ data: invitationResponse }, { data: pdfData }] = await Promise.all([
getPublicInvitation(token),
downloadPublicInvitationDocument(token),
]);
setInvitation(invitationResponse.data);
setPdfBlob(pdfData);
setShowTermsModal(!invitationResponse.data.legalAcceptedAt);
if (invitationResponse.data.status === 'signed') {
setCompletedState({
orderIndex: invitationResponse.data.orderIndex,
totalInvites: invitationResponse.data.totalInvites,
completed: true,
nextInviteeName: null,
alreadySigned: true,
});
}
} catch (error) {
toast.error(error.response?.data?.error || 'No se pudo abrir la invitación');
} finally {
setLoading(false);
}
};
load();
}, [token]);
const handleAcceptTerms = async () => {
try {
await acceptPublicInvitationTerms(token);
setShowTermsModal(false);
setInvitation((current) => ({
...current,
legalAcceptedAt: new Date().toISOString(),
}));
toast.success('Condiciones aceptadas');
} catch (error) {
toast.error(error.response?.data?.error || 'No se pudieron aceptar las condiciones');
}
};
const isReadyToSign = useMemo(
() => Boolean(invitation?.legalAcceptedAt && certificateFile && certPassword && position),
[invitation?.legalAcceptedAt, certificateFile, certPassword, position]
);
const handleSubmit = async (event) => {
event.preventDefault();
if (!isReadyToSign) {
toast.error('Completa la posición de firma y el certificado P12/PFX');
return;
}
setSubmitting(true);
try {
const { data } = await signPublicInvitation(token, {
certificateFile,
certPassword,
page: position.page,
rect: position.rect,
acceptedTerms: true,
reason,
location,
});
setCompletedState(data.data);
toast.success('Documento firmado correctamente');
} catch (error) {
toast.error(error.response?.data?.error || 'No se pudo completar la firma');
} finally {
setSubmitting(false);
}
};
if (loading) {
return (
<div className="auth-page">
<div className="auth-card">
<div className="loading-area"><div className="spinner" /></div>
</div>
</div>
);
}
if (!invitation) {
return (
<div className="auth-page">
<div className="auth-card">
<h2>Invitación no disponible</h2>
<p>El enlace no está activo o ya expiró.</p>
</div>
</div>
);
}
return (
<div className="auth-page guest-invitation-page">
{showTermsModal && (
<div className="modal-overlay">
<div className="modal-card invitation-terms-modal">
<div className="modal-title">
<ShieldCheck size={18} />
Privacidad y uso de la firma invitada
</div>
<p className="modal-desc">
LinkedSign procesará esta firma de forma puntual para completar la cadena del documento.
Tu certificado P12/PFX y la contraseña que ingreses no serán guardados por el sistema.
</p>
<p className="modal-desc">
Esta invitación requiere obligatoriamente firma electrónica con tu propio certificado P12/PFX.
</p>
<div className="modal-actions">
<button className="btn btn-primary" type="button" onClick={handleAcceptTerms}>
Acepto y continuar
</button>
</div>
</div>
</div>
)}
<div className="auth-card guest-invitation-card">
<div className="guest-invitation-header">
<div>
<h1>Firma invitada</h1>
<p>{invitation.documentTitle}</p>
</div>
<div className="guest-invitation-order">
Firma {invitation.orderIndex} de {invitation.totalInvites}
</div>
</div>
{completedState ? (
<div className="form-card invitation-completed-card">
<div className="invitation-completed-icon">
<MailCheck size={24} />
</div>
<h3>Firma completada</h3>
<p>La firma se registró correctamente y el usuario principal ya fue notificado.</p>
<div className="sign-confirm-details">
<span><strong>Documento:</strong> {invitation.documentTitle}</span>
<span><strong>Orden atendido:</strong> {completedState.orderIndex} de {completedState.totalInvites}</span>
<span>
<strong>Siguiente paso:</strong>{' '}
{completedState.alreadySigned
? 'Esta invitación ya había sido utilizada anteriormente.'
: completedState.completed
? 'La cadena de firmas terminó.'
: `El sistema enviará automáticamente la solicitud a ${completedState.nextInviteeName}.`}
</span>
</div>
</div>
) : (
<>
<div className="form-card invitation-info-card">
<div className="invitation-info-line">
<FileText size={16} />
<span><strong>Documento:</strong> {invitation.documentTitle}</span>
</div>
<div className="invitation-info-line">
<AlertTriangle size={16} />
<span>Debes usar tu propio certificado electrónico P12/PFX.</span>
</div>
<div className="invitation-info-line">
<LockKeyhole size={16} />
<span>Los datos de tu certificado y su contraseña no serán guardados.</span>
</div>
<div className="invitation-info-line">
<ShieldCheck size={16} />
<span>El estilo aplicado seguirá la configuración del usuario principal.</span>
</div>
<div className="invitation-info-line">
<span><strong>Invitación válida hasta:</strong> {formatExpiry(invitation.tokenExpires)}</span>
</div>
</div>
{pdfBlob && (
<div className="form-card">
<h3>Ubica tu firma</h3>
<PdfSignaturePlacer
pdfBlob={pdfBlob}
signerName={invitation.inviteeName}
signatureStyle={invitation.signatureStyle}
qrPosition={invitation.qrPosition}
qrBackground={invitation.qrBackground}
qrBorder={invitation.qrBorder}
onPlaced={setPosition}
/>
</div>
)}
<div className="form-card">
<h3>Completar firma con P12/PFX</h3>
<form onSubmit={handleSubmit}>
<div className="form-group">
<label>Certificado P12/PFX</label>
<input
type="file"
accept=".p12,.pfx,application/x-pkcs12"
onChange={(event) => setCertificateFile(event.target.files?.[0] || null)}
/>
<small className="form-help">
Este certificado se usará solo para esta firma y no será guardado por el sistema.
</small>
</div>
<div className="form-group">
<label>Contraseña del certificado</label>
<input
type="password"
value={certPassword}
onChange={(event) => setCertPassword(event.target.value)}
placeholder="Contraseña del P12/PFX"
required
/>
</div>
<div className="form-group">
<label>Motivo de firma</label>
<input
type="text"
value={reason}
onChange={(event) => setReason(event.target.value)}
placeholder="Firmado por invitación segura"
/>
</div>
<div className="form-group">
<label>Ubicación</label>
<input
type="text"
value={location}
onChange={(event) => setLocation(event.target.value)}
placeholder="Ciudad o lugar de firma"
/>
</div>
<button className="btn btn-primary" type="submit" disabled={submitting || !isReadyToSign}>
<Upload size={16} />
{submitting ? 'Firmando...' : 'Firmar documento'}
</button>
</form>
</div>
</>
)}
</div>
</div>
);
}