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
+2
View File
@@ -16,6 +16,7 @@ import SignaturesPage from './pages/signatures/SignaturesPage';
import VerifySignaturePage from './pages/verify/VerifySignaturePage';
import LegalPage from './pages/legal/LegalPage';
import AuditPage from './pages/audit/AuditPage';
import GuestInvitationPage from './pages/invitations/GuestInvitationPage';
export default function App() {
return (
@@ -28,6 +29,7 @@ export default function App() {
<Route path="/auth/verify-email/:token" element={<VerifyEmailPage />} />
<Route path="/auth/confirm-password-change/:token" element={<ConfirmPasswordChangePage />} />
<Route path="/auth/unlock-account/:token" element={<UnlockAccountPage />} />
<Route path="/guest-sign/:token" element={<GuestInvitationPage />} />
<Route path="/verify/:code" element={<VerifySignaturePage />} />
<Route path="/" element={<Navigate to="/dashboard" replace />} />
<Route
+30
View File
@@ -0,0 +1,30 @@
import client from './client';
export const createInvitationFlow = (payload) => client.post('/invitations', payload);
export const getInvitationFlow = (documentId) => client.get(`/invitations/document/${documentId}`);
export const addInvitationFlowInvitees = (documentId, payload) => client.post(`/invitations/document/${documentId}/invitees`, payload);
export const deleteInvitation = (invitationId) => client.delete(`/invitations/${invitationId}`);
export const getPublicInvitation = (token) => client.get(`/invitations/public/${token}`);
export const acceptPublicInvitationTerms = (token) => client.post(`/invitations/public/${token}/accept-terms`);
export const downloadPublicInvitationDocument = (token) => client.get(`/invitations/public/${token}/document`, {
responseType: 'blob',
});
export const signPublicInvitation = (token, payload) => {
const form = new FormData();
form.append('certificateFile', payload.certificateFile);
form.append('certPassword', payload.certPassword);
form.append('page', String(payload.page));
form.append('rect', JSON.stringify(payload.rect));
form.append('acceptedTerms', payload.acceptedTerms ? 'true' : 'false');
if (payload.reason) form.append('reason', payload.reason);
if (payload.location) form.append('location', payload.location);
return client.post(`/invitations/public/${token}/sign`, form, {
headers: { 'Content-Type': 'multipart/form-data' },
});
};
+178
View File
@@ -1954,6 +1954,173 @@ select {
box-shadow: 0 4px 20px rgba(0,0,0,.15);
}
.guest-invitation-page {
padding: 32px 16px;
}
.guest-invitation-card {
width: min(1100px, 100%);
max-width: 1100px;
}
.guest-invitation-header {
display: flex;
justify-content: space-between;
gap: 16px;
align-items: flex-start;
margin-bottom: 18px;
}
.guest-invitation-header h1 {
margin: 0 0 8px;
font-size: 28px;
}
.guest-invitation-header p {
margin: 0;
color: var(--gray-500);
}
.guest-invitation-order {
display: inline-flex;
align-items: center;
padding: 8px 12px;
border-radius: 999px;
background: var(--c-info-bg);
color: var(--c-primary);
font-size: 13px;
font-weight: 600;
}
.invitation-terms-modal {
max-width: 520px;
}
.invitation-info-card,
.invitation-owner-card,
.invitation-completed-card {
gap: 12px;
}
.invitation-info-line {
display: flex;
align-items: flex-start;
gap: 10px;
color: var(--gray-700);
font-size: 14px;
line-height: 1.5;
}
.invitation-info-line svg {
flex-shrink: 0;
margin-top: 2px;
color: var(--c-primary);
}
.invitation-completed-card {
text-align: center;
}
.invitation-completed-icon {
width: 56px;
height: 56px;
border-radius: 50%;
margin: 0 auto 6px;
display: flex;
align-items: center;
justify-content: center;
background: var(--c-success-bg);
color: var(--c-success);
}
.invitation-modal {
width: min(920px, 96vw);
}
.invitation-modal-body {
align-items: stretch;
}
.invitation-owner-warnings {
display: flex;
flex-direction: column;
gap: 10px;
}
.invitation-draft-list,
.invitation-order-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.invitation-draft-row,
.invitation-order-row {
border: 1px solid var(--gray-200);
border-radius: 12px;
padding: 14px;
background: white;
}
.invitation-order-row {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: center;
}
.invitation-order-actions {
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
}
.invitation-order-row strong {
display: block;
margin-bottom: 4px;
color: var(--gray-900);
}
.invitation-order-row span:not(.badge) {
display: block;
color: var(--gray-500);
font-size: 13px;
}
.invitation-status-badge {
text-transform: capitalize;
}
.invitation-status-queued {
background: var(--gray-100);
color: var(--gray-600);
}
.invitation-status-sent,
.invitation-status-opened {
background: var(--c-info-bg);
color: var(--c-primary);
}
.invitation-status-signed {
background: var(--c-success-bg);
color: var(--c-success);
}
.invitation-owner-actions {
margin-top: 8px;
}
.invitation-remove-btn {
align-self: flex-start;
}
.invitation-inline-delete {
min-width: 92px;
justify-content: center;
}
/* ─── Responsive ─────────────────────────────────────────────── */
@media (max-width: 768px) {
.app-layout {
@@ -2185,6 +2352,17 @@ select {
flex-wrap: wrap;
}
.guest-invitation-header,
.invitation-order-row {
flex-direction: column;
align-items: stretch;
}
.invitation-order-actions {
width: 100%;
justify-content: space-between;
}
.style-card-inline-tools,
.image-upload-area {
justify-content: flex-start;
+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>
);
}