Agregar firmas externas seguras y mejorar entregabilidad de correos
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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' },
|
||||
});
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user