From 1e965b6e71dfef7a4ad7e119a5a9f0522b414fb4 Mon Sep 17 00:00:00 2001 From: Milton Gonzalez Date: Wed, 8 Apr 2026 22:58:24 -0500 Subject: [PATCH] Agregar firmas externas seguras y mejorar entregabilidad de correos --- DEPLOY_ANDROID.md | 5 + DEPLOY_WEB.md | 33 +- .../certificates/certificates.service.js | 10 +- .../invitations/invitations.controller.js | 105 ++++ .../invitations/invitations.repository.js | 333 ++++++++++ .../modules/invitations/invitations.routes.js | 20 + .../invitations/invitations.service.js | 574 ++++++++++++++++++ .../invitations/invitations.validator.js | 86 +++ backend/src/modules/mail/mail.repository.js | 6 +- backend/src/modules/mail/mail.service.js | 260 ++++++-- .../signatures/signatures.repository.js | 23 + backend/src/routes/index.js | 2 + backend/src/server.js | 2 + frontend/src/App.jsx | 2 + frontend/src/api/invitations.js | 30 + frontend/src/index.css | 178 ++++++ .../src/pages/documents/DocumentsPage.jsx | 273 ++++++++- .../pages/invitations/GuestInvitationPage.jsx | 282 +++++++++ scripts/deploy_prod_web.sh | 41 ++ 19 files changed, 2185 insertions(+), 80 deletions(-) create mode 100644 backend/src/modules/invitations/invitations.controller.js create mode 100644 backend/src/modules/invitations/invitations.repository.js create mode 100644 backend/src/modules/invitations/invitations.routes.js create mode 100644 backend/src/modules/invitations/invitations.service.js create mode 100644 backend/src/modules/invitations/invitations.validator.js create mode 100644 frontend/src/api/invitations.js create mode 100644 frontend/src/pages/invitations/GuestInvitationPage.jsx create mode 100755 scripts/deploy_prod_web.sh diff --git a/DEPLOY_ANDROID.md b/DEPLOY_ANDROID.md index f1df046..88a7a81 100644 --- a/DEPLOY_ANDROID.md +++ b/DEPLOY_ANDROID.md @@ -43,3 +43,8 @@ - En producción no debe quedar `android/` fuente; solo `/opt/apps/linkedsign/android/apk/LinkedSign-release.apk`. - En cada paso a producción Android se publica únicamente el `APK release`; no se sube el código fuente Android. - El `APK release` que se publique debe corresponder a una versión nueva y anotada en `VERSION` y `CHANGELOG.md`. +- El paso Android no debe tocar ni reemplazar: + - `/opt/apps/linkedsign/backend/.env` + - `/opt/apps/linkedsign/storage/` + - `/opt/apps/linkedsign/logs/` + - configuración runtime hardened ya existente en producción diff --git a/DEPLOY_WEB.md b/DEPLOY_WEB.md index 2686463..ff604fa 100644 --- a/DEPLOY_WEB.md +++ b/DEPLOY_WEB.md @@ -27,31 +27,30 @@ - `git fetch origin main` - `git reset --hard origin/main` - `rm -rf /opt/apps/linkedsign/source/android /opt/apps/linkedsign/source/android_react /opt/apps/linkedsign/source/android_firma_electronica.html` -8. Recompila frontend: - - `cd /opt/apps/linkedsign/source/frontend` - - `npm ci` - - `npm run build` -9. Publica frontend: - - `rm -rf /opt/apps/linkedsign/frontend/*` - - `cp -r dist /opt/apps/linkedsign/frontend/` - - `cp server.js /opt/apps/linkedsign/frontend/` -10. Publica backend: - - `rm -rf /opt/apps/linkedsign/backend/*` - - `cp -r /opt/apps/linkedsign/source/backend/* /opt/apps/linkedsign/backend/` - - `cd /opt/apps/linkedsign/backend` - - `npm ci --omit=dev` -11. Reinicia servicios: +8. En producción usa el script seguro: + - `cd /opt/apps/linkedsign/source` + - `bash scripts/deploy_prod_web.sh` +9. Reinicia servicios: - `sudo systemctl restart linkedsign-frontend` - `sudo systemctl restart linkedsign-backend` -12. Limpia archivos `.md` en producción: +10. Limpia archivos `.md` en producción: - `find /opt/apps/linkedsign -type f -iname '*.md' -delete` -13. Verifica: +11. Verifica: - `curl -I http://127.0.0.1:3000` - `curl http://127.0.0.1:4000/health` ## Notas -- Si cambias variables de entorno, actualiza `/opt/apps/linkedsign/backend/.env`. +- Nunca copies `.env` desde `dev` a `prod`. +- El archivo runtime de producción es: + - `/opt/apps/linkedsign/backend/.env` +- Si cambias variables de entorno, actualiza solo ese archivo runtime. - Si cambias rutas del APK o storage, revisa también `PRODUCCION.md`. - En producción no debe quedar código fuente Android; solo el APK release en `/opt/apps/linkedsign/android/apk/`. - Si en el despliegue hay cambios Android, recuerda que a producción solo pasa el `APK release` versionado; no el proyecto Android completo. +- El deploy web no debe tocar ni reemplazar: + - `/opt/apps/linkedsign/backend/.env` + - `/opt/apps/linkedsign/storage/` + - `/opt/apps/linkedsign/logs/` + - `/opt/apps/linkedsign/android/apk/` + - permisos hardened de producción diff --git a/backend/src/modules/certificates/certificates.service.js b/backend/src/modules/certificates/certificates.service.js index 2f8cd2b..c9f77f0 100644 --- a/backend/src/modules/certificates/certificates.service.js +++ b/backend/src/modules/certificates/certificates.service.js @@ -167,4 +167,12 @@ const loadForSigning = async (certId, userId, password) => { return { privateKey, certificate }; }; -module.exports = { upload, list, getOne, remove, loadForSigning, getLatestActiveForUser }; +module.exports = { + upload, + list, + getOne, + remove, + loadForSigning, + getLatestActiveForUser, + parseCertificate, +}; diff --git a/backend/src/modules/invitations/invitations.controller.js b/backend/src/modules/invitations/invitations.controller.js new file mode 100644 index 0000000..e7f1574 --- /dev/null +++ b/backend/src/modules/invitations/invitations.controller.js @@ -0,0 +1,105 @@ +const invitationsService = require('./invitations.service'); +const { sendSuccess } = require('../../utils/apiResponse'); + +const getCtx = (req) => ({ + ip: req.ip?.replace('::ffff:', ''), + userAgent: req.headers['user-agent'], + userEmail: req.user?.email || null, +}); + +const createFlow = async (req, res, next) => { + try { + const result = await invitationsService.createFlow(req.body, req.user.id, getCtx(req)); + return sendSuccess(res, result, 201); + } catch (error) { + return next(error); + } +}; + +const getFlowForOwner = async (req, res, next) => { + try { + const result = await invitationsService.getFlowForOwner(Number(req.params.documentId), req.user.id); + return sendSuccess(res, result, 200); + } catch (error) { + return next(error); + } +}; + +const addInviteesToFlow = async (req, res, next) => { + try { + const result = await invitationsService.addInviteesToFlow(Number(req.params.documentId), req.body, req.user.id, getCtx(req)); + return sendSuccess(res, result, 200); + } catch (error) { + return next(error); + } +}; + +const removeInvitation = async (req, res, next) => { + try { + const result = await invitationsService.removeInvitation(Number(req.params.invitationId), req.user.id, getCtx(req)); + return sendSuccess(res, result, 200); + } catch (error) { + return next(error); + } +}; + +const getPublicInvitation = async (req, res, next) => { + try { + const result = await invitationsService.getPublicInvitation(req.params.token, getCtx(req)); + return sendSuccess(res, result, 200); + } catch (error) { + return next(error); + } +}; + +const downloadPublicDocument = async (req, res, next) => { + try { + const { filePath, filename } = await invitationsService.getPublicDocumentFile(req.params.token, getCtx(req)); + res.sendFile(filePath, { + headers: { + 'Content-Type': 'application/pdf', + 'Content-Disposition': `inline; filename="${filename}"`, + }, + }); + } catch (error) { + return next(error); + } +}; + +const acceptTerms = async (req, res, next) => { + try { + const result = await invitationsService.acceptTerms(req.params.token, getCtx(req)); + return sendSuccess(res, result, 200); + } catch (error) { + return next(error); + } +}; + +const signInvitation = async (req, res, next) => { + try { + const rect = typeof req.body.rect === 'string' ? JSON.parse(req.body.rect) : req.body.rect; + const result = await invitationsService.signInvitation({ + token: req.params.token, + certificateFile: req.file, + certPassword: req.body.certPassword, + page: Number(req.body.page) || 0, + rect, + reason: req.body.reason, + location: req.body.location, + }, getCtx(req)); + return sendSuccess(res, result, 200); + } catch (error) { + return next(error); + } +}; + +module.exports = { + createFlow, + getFlowForOwner, + addInviteesToFlow, + removeInvitation, + getPublicInvitation, + downloadPublicDocument, + acceptTerms, + signInvitation, +}; diff --git a/backend/src/modules/invitations/invitations.repository.js b/backend/src/modules/invitations/invitations.repository.js new file mode 100644 index 0000000..9feebae --- /dev/null +++ b/backend/src/modules/invitations/invitations.repository.js @@ -0,0 +1,333 @@ +const db = require('../../config/db'); + +const ensureSchema = async () => { + await db.execute(` + CREATE TABLE IF NOT EXISTS invitation_flows ( + id INT AUTO_INCREMENT PRIMARY KEY, + document_id INT NOT NULL, + owner_user_id INT NOT NULL, + base_signature_id INT NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'active', + current_signed_filename VARCHAR(255) NOT NULL, + current_signed_path VARCHAR(500) NOT NULL, + signature_style VARCHAR(32) NOT NULL DEFAULT 'qr', + qr_position VARCHAR(16) NULL, + qr_background VARCHAR(16) NULL, + qr_border VARCHAR(16) NULL, + signature_image_base64 MEDIUMTEXT NULL, + total_invites INT NOT NULL DEFAULT 0, + completed_invites INT NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_invitation_flows_document_owner (document_id, owner_user_id), + INDEX idx_invitation_flows_status (status) + ) + `); + + await db.execute(` + CREATE TABLE IF NOT EXISTS invitation_signers ( + id INT AUTO_INCREMENT PRIMARY KEY, + flow_id INT NOT NULL, + owner_user_id INT NOT NULL, + document_id INT NOT NULL, + order_index INT NOT NULL, + invitee_name VARCHAR(160) NOT NULL, + invitee_email VARCHAR(255) NOT NULL, + token VARCHAR(255) NOT NULL, + token_expires DATETIME NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'queued', + legal_accepted_at DATETIME NULL, + viewed_at DATETIME NULL, + sent_at DATETIME NULL, + signed_at DATETIME NULL, + signed_filename VARCHAR(255) NULL, + signed_path VARCHAR(500) NULL, + certificate_subject_cn VARCHAR(255) NULL, + certificate_issuer_cn VARCHAR(255) NULL, + verification_code VARCHAR(100) NULL, + verification_url VARCHAR(500) NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uq_invitation_signers_token (token), + UNIQUE KEY uq_invitation_signers_flow_order (flow_id, order_index), + INDEX idx_invitation_signers_document_owner (document_id, owner_user_id), + INDEX idx_invitation_signers_status (status) + ) + `); +}; + +const createFlow = async ({ + documentId, + ownerUserId, + baseSignatureId, + currentSignedFilename, + currentSignedPath, + signatureStyle, + qrPosition, + qrBackground, + qrBorder, + signatureImageBase64, + totalInvites, +}) => { + const [result] = await db.execute( + `INSERT INTO invitation_flows + (document_id, owner_user_id, base_signature_id, current_signed_filename, current_signed_path, + signature_style, qr_position, qr_background, qr_border, signature_image_base64, total_invites) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + documentId, + ownerUserId, + baseSignatureId, + currentSignedFilename, + currentSignedPath, + signatureStyle, + qrPosition || null, + qrBackground || null, + qrBorder || null, + signatureImageBase64 || null, + totalInvites, + ] + ); + return result.insertId; +}; + +const createInvitation = async ({ + flowId, + ownerUserId, + documentId, + orderIndex, + inviteeName, + inviteeEmail, + token, + tokenExpires, + status, +}) => { + const [result] = await db.execute( + `INSERT INTO invitation_signers + (flow_id, owner_user_id, document_id, order_index, invitee_name, invitee_email, token, token_expires, status) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [flowId, ownerUserId, documentId, orderIndex, inviteeName, inviteeEmail, token, tokenExpires, status] + ); + return result.insertId; +}; + +const findFlowByDocumentOwner = async (documentId, ownerUserId) => { + const [rows] = await db.execute( + 'SELECT * FROM invitation_flows WHERE document_id = ? AND owner_user_id = ? ORDER BY id DESC LIMIT 1', + [documentId, ownerUserId] + ); + return rows[0] || null; +}; + +const findFlowById = async (id) => { + const [rows] = await db.execute('SELECT * FROM invitation_flows WHERE id = ?', [id]); + return rows[0] || null; +}; + +const listInvitationsByFlow = async (flowId) => { + const [rows] = await db.execute( + 'SELECT * FROM invitation_signers WHERE flow_id = ? ORDER BY order_index ASC', + [flowId] + ); + return rows; +}; + +const getMaxOrderIndex = async (flowId) => { + const [rows] = await db.execute( + `SELECT COALESCE(MAX(order_index), 0) AS maxOrder + FROM invitation_signers + WHERE flow_id = ?`, + [flowId] + ); + return Number(rows[0]?.maxOrder || 0); +}; + +const findInvitationByIdForOwner = async (id, ownerUserId) => { + const [rows] = await db.execute( + `SELECT i.*, f.status as flow_status, f.current_signed_filename, f.current_signed_path, + f.signature_style, f.qr_position, f.qr_background, f.qr_border, f.signature_image_base64, + f.base_signature_id, f.total_invites, f.completed_invites, + d.title as document_title, d.filename as document_filename, + u.name as owner_name, u.email as owner_email + FROM invitation_signers i + JOIN invitation_flows f ON f.id = i.flow_id + JOIN documents d ON d.id = i.document_id + JOIN users u ON u.id = i.owner_user_id + WHERE i.id = ? AND i.owner_user_id = ? + LIMIT 1`, + [id, ownerUserId] + ); + return rows[0] || null; +}; + +const findInvitationByToken = async (token) => { + const [rows] = await db.execute( + `SELECT i.*, f.status as flow_status, f.current_signed_filename, f.current_signed_path, + f.signature_style, f.qr_position, f.qr_background, f.qr_border, f.signature_image_base64, + f.base_signature_id, f.total_invites, f.completed_invites, + d.title as document_title, d.filename as document_filename, + u.name as owner_name, u.email as owner_email + FROM invitation_signers i + JOIN invitation_flows f ON f.id = i.flow_id + JOIN documents d ON d.id = i.document_id + JOIN users u ON u.id = i.owner_user_id + WHERE i.token = ? + LIMIT 1`, + [token] + ); + return rows[0] || null; +}; + +const markInvitationSent = async (id) => { + await db.execute( + `UPDATE invitation_signers + SET status = 'sent', + sent_at = COALESCE(sent_at, NOW()) + WHERE id = ?`, + [id] + ); +}; + +const markInvitationViewed = async (id) => { + await db.execute( + `UPDATE invitation_signers + SET status = CASE WHEN status = 'sent' THEN 'opened' ELSE status END, + viewed_at = COALESCE(viewed_at, NOW()) + WHERE id = ?`, + [id] + ); +}; + +const markInvitationTermsAccepted = async (id) => { + await db.execute( + `UPDATE invitation_signers + SET legal_accepted_at = COALESCE(legal_accepted_at, NOW()) + WHERE id = ?`, + [id] + ); +}; + +const markInvitationSigned = async ({ + id, + signedFilename, + signedPath, + certificateSubjectCn, + certificateIssuerCn, + verificationCode, + verificationUrl, +}) => { + await db.execute( + `UPDATE invitation_signers + SET status = 'signed', + signed_at = NOW(), + signed_filename = ?, + signed_path = ?, + certificate_subject_cn = ?, + certificate_issuer_cn = ?, + verification_code = ?, + verification_url = ? + WHERE id = ?`, + [signedFilename, signedPath, certificateSubjectCn || null, certificateIssuerCn || null, verificationCode || null, verificationUrl || null, id] + ); +}; + +const invalidateInvitationToken = async (id, replacementToken) => { + await db.execute( + `UPDATE invitation_signers + SET token = ?, token_expires = NOW() + WHERE id = ?`, + [replacementToken, id] + ); +}; + +const activateInvitation = async (id) => { + await db.execute( + `UPDATE invitation_signers + SET status = 'sent', + sent_at = COALESCE(sent_at, NOW()) + WHERE id = ?`, + [id] + ); +}; + +const updateFlowProgress = async ({ flowId, currentSignedFilename, currentSignedPath, completedInvites, status }) => { + await db.execute( + `UPDATE invitation_flows + SET current_signed_filename = ?, + current_signed_path = ?, + completed_invites = ?, + status = ? + WHERE id = ?`, + [currentSignedFilename, currentSignedPath, completedInvites, status, flowId] + ); +}; + +const findNextInvitation = async (flowId) => { + const [rows] = await db.execute( + `SELECT * FROM invitation_signers + WHERE flow_id = ? AND status = 'queued' + ORDER BY order_index ASC + LIMIT 1`, + [flowId] + ); + return rows[0] || null; +}; + +const countSignedInvitations = async (flowId) => { + const [rows] = await db.execute( + `SELECT COUNT(*) AS total + FROM invitation_signers + WHERE flow_id = ? AND status = 'signed'`, + [flowId] + ); + return Number(rows[0]?.total || 0); +}; + +const countInvitations = async (flowId) => { + const [rows] = await db.execute( + `SELECT COUNT(*) AS total + FROM invitation_signers + WHERE flow_id = ?`, + [flowId] + ); + return Number(rows[0]?.total || 0); +}; + +const updateFlowCounts = async ({ flowId, totalInvites, completedInvites, status }) => { + await db.execute( + `UPDATE invitation_flows + SET total_invites = ?, + completed_invites = ?, + status = ? + WHERE id = ?`, + [totalInvites, completedInvites, status, flowId] + ); +}; + +const removeInvitation = async (id) => { + await db.execute('DELETE FROM invitation_signers WHERE id = ?', [id]); +}; + +module.exports = { + ensureSchema, + createFlow, + createInvitation, + findFlowByDocumentOwner, + findFlowById, + listInvitationsByFlow, + getMaxOrderIndex, + findInvitationByIdForOwner, + findInvitationByToken, + markInvitationSent, + markInvitationViewed, + markInvitationTermsAccepted, + markInvitationSigned, + invalidateInvitationToken, + activateInvitation, + updateFlowProgress, + findNextInvitation, + countSignedInvitations, + countInvitations, + updateFlowCounts, + removeInvitation, +}; diff --git a/backend/src/modules/invitations/invitations.routes.js b/backend/src/modules/invitations/invitations.routes.js new file mode 100644 index 0000000..3f0835a --- /dev/null +++ b/backend/src/modules/invitations/invitations.routes.js @@ -0,0 +1,20 @@ +const express = require('express'); +const authenticate = require('../../middlewares/authenticate'); +const { uploadTempCertificate } = require('../../config/multer'); +const invitationsController = require('./invitations.controller'); +const { validate, createInvitationsSchema, addInviteesSchema, validateGuestMultipart } = require('./invitations.validator'); + +const router = express.Router(); + +router.get('/public/:token', invitationsController.getPublicInvitation); +router.get('/public/:token/document', invitationsController.downloadPublicDocument); +router.post('/public/:token/accept-terms', invitationsController.acceptTerms); +router.post('/public/:token/sign', uploadTempCertificate.single('certificateFile'), validateGuestMultipart, invitationsController.signInvitation); + +router.use(authenticate); +router.post('/', validate(createInvitationsSchema), invitationsController.createFlow); +router.get('/document/:documentId', invitationsController.getFlowForOwner); +router.post('/document/:documentId/invitees', validate(addInviteesSchema), invitationsController.addInviteesToFlow); +router.delete('/:invitationId', invitationsController.removeInvitation); + +module.exports = router; diff --git a/backend/src/modules/invitations/invitations.service.js b/backend/src/modules/invitations/invitations.service.js new file mode 100644 index 0000000..df862d6 --- /dev/null +++ b/backend/src/modules/invitations/invitations.service.js @@ -0,0 +1,574 @@ +const fs = require('fs/promises'); +const path = require('path'); +const crypto = require('crypto'); +const { v4: uuidv4 } = require('uuid'); +const invitationsRepository = require('./invitations.repository'); +const documentsRepository = require('../documents/documents.repository'); +const signaturesRepository = require('../signatures/signatures.repository'); +const certificatesService = require('../certificates/certificates.service'); +const usersRepository = require('../users/users.repository'); +const { signPdf } = require('../signing/signing.service'); +const auditService = require('../audit/audit.service'); +const mailService = require('../mail/mail.service'); +const env = require('../../config/env'); + +const INVITATION_EXPIRY_DAYS = 7; + +const buildGuestUrl = (token) => `${env.frontendUrl.replace(/\/$/, '')}/guest-sign/${token}`; + +const sanitizeBaseName = (value) => String(value || 'documento') + .replace(/\.pdf$/i, '') + .replace(/[^a-zA-Z0-9._-]/g, '_'); + +const createInvitationToken = () => crypto.randomBytes(24).toString('hex'); +const createConsumedToken = () => `used_${crypto.randomBytes(24).toString('hex')}`; + +const createExpiryDate = () => { + const date = new Date(); + date.setDate(date.getDate() + INVITATION_EXPIRY_DAYS); + return date; +}; + +const ensureOwnerCanInvite = async (documentId, ownerUserId) => { + const document = await documentsRepository.findById(documentId); + if (!document) { + const error = new Error('Document not found.'); + error.statusCode = 404; + throw error; + } + if (document.user_id !== ownerUserId) { + const error = new Error('Access denied.'); + error.statusCode = 403; + throw error; + } + if (document.status !== 'signed') { + const error = new Error('El documento debe estar firmado por el usuario antes de invitar terceros.'); + error.statusCode = 409; + throw error; + } + + const baseSignature = await signaturesRepository.findLatestByDocumentAndUser(documentId, ownerUserId); + if (!baseSignature) { + const error = new Error('No se encontró la firma base del usuario principal.'); + error.statusCode = 409; + throw error; + } + + return { document, baseSignature }; +}; + +const createFlow = async ({ documentId, invitees }, ownerUserId, context = {}) => { + if (invitees.length > 5) { + const error = new Error('Solo puedes invitar hasta 5 firmantes externos.'); + error.statusCode = 400; + throw error; + } + + const existingFlow = await invitationsRepository.findFlowByDocumentOwner(documentId, ownerUserId); + if (existingFlow && existingFlow.status !== 'completed') { + const error = new Error('Este documento ya tiene una cadena de firmas externas en curso.'); + error.statusCode = 409; + throw error; + } + + const { document, baseSignature } = await ensureOwnerCanInvite(documentId, ownerUserId); + const ownerPreferences = await usersRepository.getSignaturePreferences(ownerUserId); + + const flowId = await invitationsRepository.createFlow({ + documentId, + ownerUserId, + baseSignatureId: baseSignature.id, + currentSignedFilename: baseSignature.signed_filename, + currentSignedPath: baseSignature.storage_path, + signatureStyle: ownerPreferences.signatureStyle, + qrPosition: ownerPreferences.qrPosition, + qrBackground: ownerPreferences.qrBackground, + qrBorder: ownerPreferences.qrBorder, + signatureImageBase64: ownerPreferences.signatureImageBase64, + totalInvites: invitees.length, + }); + + const flow = await invitationsRepository.findFlowById(flowId); + const created = []; + + for (let index = 0; index < invitees.length; index += 1) { + const invitee = invitees[index]; + const token = createInvitationToken(); + const invitationId = await invitationsRepository.createInvitation({ + flowId, + ownerUserId, + documentId, + orderIndex: index + 1, + inviteeName: invitee.name.trim(), + inviteeEmail: invitee.email.trim().toLowerCase(), + token, + tokenExpires: createExpiryDate(), + status: index === 0 ? 'sent' : 'queued', + }); + + if (index === 0) { + await invitationsRepository.markInvitationSent(invitationId); + await mailService.sendExternalSigningInvitationEmail({ + ownerName: document.owner_name || document.owner_email, + inviteeName: invitee.name.trim(), + inviteeEmail: invitee.email.trim().toLowerCase(), + documentTitle: document.title, + inviteUrl: buildGuestUrl(token), + orderIndex: 1, + totalInvites: invitees.length, + }); + } + + created.push({ + id: invitationId, + inviteeName: invitee.name.trim(), + inviteeEmail: invitee.email.trim().toLowerCase(), + orderIndex: index + 1, + status: index === 0 ? 'sent' : 'queued', + inviteUrl: index === 0 ? buildGuestUrl(token) : null, + }); + } + + await auditService.logEvent({ + type: 'EXTERNAL_SIGNING_FLOW_CREATED', + userId: ownerUserId, + email: context.userEmail || null, + ip: context.ip, + userAgent: context.userAgent, + metadata: { + documentId, + flowId, + totalInvites: invitees.length, + onlyP12Allowed: true, + }, + }); + + return { + flowId, + documentId, + documentTitle: document.title, + status: flow.status, + totalInvites: invitees.length, + completedInvites: 0, + signatureStyle: ownerPreferences.signatureStyle, + invitees: created, + warnings: [ + 'Solo se debe invitar a personas que cuenten con firma electrónica P12/PFX.', + 'Las firmas se solicitarán en el orden configurado.', + ], + }; +}; + +const getFlowForOwner = async (documentId, ownerUserId) => { + const flow = await invitationsRepository.findFlowByDocumentOwner(documentId, ownerUserId); + if (!flow) return null; + const invitees = await invitationsRepository.listInvitationsByFlow(flow.id); + return { + ...flow, + invitees, + }; +}; + +const addInviteesToFlow = async (documentId, { invitees }, ownerUserId, context = {}) => { + if (invitees.length > 5) { + const error = new Error('Solo puedes invitar hasta 5 firmantes externos.'); + error.statusCode = 400; + throw error; + } + + const flow = await invitationsRepository.findFlowByDocumentOwner(documentId, ownerUserId); + if (!flow) { + const error = new Error('No existe una cadena de firmas externas para este documento.'); + error.statusCode = 404; + throw error; + } + + if (flow.status === 'completed') { + const error = new Error('La cadena ya fue completada. Crea una nueva si necesitas más firmas.'); + error.statusCode = 409; + throw error; + } + + const { document } = await ensureOwnerCanInvite(documentId, ownerUserId); + const existingInvitees = await invitationsRepository.listInvitationsByFlow(flow.id); + const currentCount = existingInvitees.length; + if (currentCount + invitees.length > 5) { + const error = new Error('Solo puedes tener hasta 5 firmantes externos por documento.'); + error.statusCode = 400; + throw error; + } + + const existingEmails = new Set(existingInvitees.map((item) => String(item.invitee_email).toLowerCase())); + const normalizedInvitees = invitees.map((invitee) => ({ + name: invitee.name.trim(), + email: invitee.email.trim().toLowerCase(), + })); + + for (const invitee of normalizedInvitees) { + if (existingEmails.has(invitee.email)) { + const error = new Error(`El correo ${invitee.email} ya está en la cadena de firmas.`); + error.statusCode = 409; + throw error; + } + } + + const hasActiveInvitation = existingInvitees.some((item) => ['sent', 'opened'].includes(item.status)); + let shouldActivateFirstNew = !hasActiveInvitation; + let nextOrderIndex = await invitationsRepository.getMaxOrderIndex(flow.id); + + for (const invitee of normalizedInvitees) { + nextOrderIndex += 1; + const token = createInvitationToken(); + const status = shouldActivateFirstNew ? 'sent' : 'queued'; + const invitationId = await invitationsRepository.createInvitation({ + flowId: flow.id, + ownerUserId, + documentId, + orderIndex: nextOrderIndex, + inviteeName: invitee.name, + inviteeEmail: invitee.email, + token, + tokenExpires: createExpiryDate(), + status, + }); + + if (shouldActivateFirstNew) { + await invitationsRepository.markInvitationSent(invitationId); + await mailService.sendExternalSigningInvitationEmail({ + ownerName: document.owner_name || document.owner_email, + inviteeName: invitee.name, + inviteeEmail: invitee.email, + documentTitle: document.title, + inviteUrl: buildGuestUrl(token), + orderIndex: nextOrderIndex, + totalInvites: currentCount + normalizedInvitees.length, + }); + shouldActivateFirstNew = false; + } + } + + await invitationsRepository.updateFlowCounts({ + flowId: flow.id, + totalInvites: currentCount + normalizedInvitees.length, + completedInvites: await invitationsRepository.countSignedInvitations(flow.id), + status: 'active', + }); + + await auditService.logEvent({ + type: 'EXTERNAL_SIGNING_INVITEES_ADDED', + userId: ownerUserId, + email: context.userEmail || null, + ip: context.ip, + userAgent: context.userAgent, + metadata: { + flowId: flow.id, + documentId, + addedCount: normalizedInvitees.length, + totalInvites: currentCount + normalizedInvitees.length, + }, + }); + + return getFlowForOwner(documentId, ownerUserId); +}; + +const removeInvitation = async (invitationId, ownerUserId, context = {}) => { + const invitation = await invitationsRepository.findInvitationByIdForOwner(invitationId, ownerUserId); + if (!invitation) { + const error = new Error('Invitación no encontrada.'); + error.statusCode = 404; + throw error; + } + + if (invitation.status === 'signed') { + const error = new Error('No puedes eliminar una invitación que ya fue firmada.'); + error.statusCode = 409; + throw error; + } + + const wasActive = ['sent', 'opened'].includes(invitation.status); + + await invitationsRepository.removeInvitation(invitationId); + + const completedInvites = await invitationsRepository.countSignedInvitations(invitation.flow_id); + const totalInvites = await invitationsRepository.countInvitations(invitation.flow_id); + + let nextInvitation = null; + if (wasActive) { + nextInvitation = await invitationsRepository.findNextInvitation(invitation.flow_id); + if (nextInvitation) { + await invitationsRepository.activateInvitation(nextInvitation.id); + await mailService.sendExternalSigningInvitationEmail({ + ownerName: invitation.owner_name, + inviteeName: nextInvitation.invitee_name, + inviteeEmail: nextInvitation.invitee_email, + documentTitle: invitation.document_title, + inviteUrl: buildGuestUrl(nextInvitation.token), + orderIndex: nextInvitation.order_index, + totalInvites, + }); + } + } + + const flowStatus = totalInvites === 0 + ? 'cancelled' + : completedInvites >= totalInvites + ? 'completed' + : 'active'; + + await invitationsRepository.updateFlowCounts({ + flowId: invitation.flow_id, + totalInvites, + completedInvites, + status: flowStatus, + }); + + await auditService.logEvent({ + type: 'EXTERNAL_SIGNING_INVITATION_REMOVED', + userId: ownerUserId, + email: context.userEmail || null, + ip: context.ip, + userAgent: context.userAgent, + metadata: { + invitationId, + flowId: invitation.flow_id, + documentId: invitation.document_id, + inviteeEmail: invitation.invitee_email, + nextInvitationId: nextInvitation?.id || null, + }, + }); + + return getFlowForOwner(invitation.document_id, ownerUserId); +}; + +const getPublicInvitation = async (token, context = {}) => { + const invitation = await invitationsRepository.findInvitationByToken(token); + if (!invitation) { + const error = new Error('Invitación no encontrada o expirada.'); + error.statusCode = 404; + throw error; + } + if (new Date(invitation.token_expires) < new Date()) { + const error = new Error('La invitación ha expirado.'); + error.statusCode = 410; + throw error; + } + + await invitationsRepository.markInvitationViewed(invitation.id); + await auditService.logEvent({ + type: 'EXTERNAL_SIGNING_LINK_OPENED', + email: invitation.invitee_email, + ip: context.ip, + userAgent: context.userAgent, + metadata: { + invitationId: invitation.id, + documentId: invitation.document_id, + orderIndex: invitation.order_index, + }, + }); + + return { + invitationId: invitation.id, + inviteeName: invitation.invitee_name, + inviteeEmail: invitation.invitee_email, + orderIndex: invitation.order_index, + totalInvites: invitation.total_invites, + status: invitation.status, + tokenExpires: invitation.token_expires, + legalAcceptedAt: invitation.legal_accepted_at, + ownerName: invitation.owner_name, + documentTitle: invitation.document_title, + documentFilename: invitation.current_signed_filename, + currentSignedFilename: invitation.current_signed_filename, + signatureStyle: invitation.signature_style, + qrPosition: invitation.qr_position, + qrBackground: invitation.qr_background, + qrBorder: invitation.qr_border, + warnings: [ + 'Debes usar tu propio certificado P12/PFX para firmar.', + 'Los datos de tu certificado y su contraseña no serán guardados por el sistema.', + ], + }; +}; + +const acceptTerms = async (token, context = {}) => { + const invitation = await invitationsRepository.findInvitationByToken(token); + if (!invitation) { + const error = new Error('Invitación no encontrada o expirada.'); + error.statusCode = 404; + throw error; + } + + await invitationsRepository.markInvitationTermsAccepted(invitation.id); + await auditService.logEvent({ + type: 'EXTERNAL_SIGNING_TERMS_ACCEPTED', + email: invitation.invitee_email, + ip: context.ip, + userAgent: context.userAgent, + metadata: { + invitationId: invitation.id, + documentId: invitation.document_id, + }, + }); + + return { message: 'Condiciones aceptadas.' }; +}; + +const getPublicDocumentFile = async (token, context = {}) => { + const invitation = await getPublicInvitation(token, context); + const rawInvitation = await invitationsRepository.findInvitationByToken(token); + return { + filePath: rawInvitation.current_signed_path, + filename: invitation.currentSignedFilename, + }; +}; + +const signInvitation = async ({ token, certificateFile, certPassword, page, rect, reason, location }, context = {}) => { + const invitation = await invitationsRepository.findInvitationByToken(token); + if (!invitation) { + const error = new Error('Invitación no encontrada o expirada.'); + error.statusCode = 404; + throw error; + } + if (new Date(invitation.token_expires) < new Date()) { + const error = new Error('La invitación ha expirado.'); + error.statusCode = 410; + throw error; + } + if (!invitation.legal_accepted_at) { + const error = new Error('Debes aceptar las condiciones antes de firmar.'); + error.statusCode = 409; + throw error; + } + if (invitation.status === 'signed') { + const error = new Error('Esta invitación ya fue utilizada.'); + error.statusCode = 409; + throw error; + } + if (!['sent', 'opened'].includes(invitation.status)) { + const error = new Error('Esta invitación aún no está activa.'); + error.statusCode = 409; + throw error; + } + + const outputDir = path.join(env.storagePath, 'signed', 'invited', String(invitation.flow_id)); + await fs.mkdir(outputDir, { recursive: true }); + + let parsedCertificate; + let signedResult; + + try { + const p12Buffer = await fs.readFile(certificateFile.path); + parsedCertificate = certificatesService.parseCertificate + ? certificatesService.parseCertificate(p12Buffer, certPassword) + : null; + const signerInfo = parsedCertificate || {}; + const outputBaseName = sanitizeBaseName( + `${invitation.document_title}_flow_${invitation.flow_id}_invite_${invitation.order_index}` + ); + + signedResult = await signPdf({ + pdfPath: invitation.current_signed_path, + p12Buffer, + p12Password: certPassword, + signerName: signerInfo.subjectCn || invitation.invitee_name, + reason: reason || 'Firmado por invitación segura', + location: location || '', + page, + rect, + signatureStyle: invitation.signature_style || 'qr', + qrPosition: invitation.qr_position || 'right', + qrBackground: invitation.qr_background || 'transparent', + qrBorder: invitation.qr_border || 'none', + signatureImageBase64: invitation.signature_image_base64 || null, + verificationUrl: null, + outputDir, + outputBaseName, + }); + + await invitationsRepository.markInvitationSigned({ + id: invitation.id, + signedFilename: signedResult.signedFilename, + signedPath: signedResult.signedPath, + certificateSubjectCn: signerInfo.subjectCn || null, + certificateIssuerCn: signerInfo.issuerCn || null, + verificationCode: null, + verificationUrl: null, + }); + await invitationsRepository.invalidateInvitationToken(invitation.id, createConsumedToken()); + + const completedInvites = await invitationsRepository.countSignedInvitations(invitation.flow_id); + const nextInvitation = await invitationsRepository.findNextInvitation(invitation.flow_id); + const flowStatus = nextInvitation ? 'active' : 'completed'; + + await invitationsRepository.updateFlowProgress({ + flowId: invitation.flow_id, + currentSignedFilename: signedResult.signedFilename, + currentSignedPath: signedResult.signedPath, + completedInvites, + status: flowStatus, + }); + + await signaturesRepository.updateSignedArtifact(invitation.base_signature_id, signedResult.signedFilename, signedResult.signedPath); + + if (nextInvitation) { + await invitationsRepository.activateInvitation(nextInvitation.id); + await mailService.sendExternalSigningInvitationEmail({ + ownerName: invitation.owner_name, + inviteeName: nextInvitation.invitee_name, + inviteeEmail: nextInvitation.invitee_email, + documentTitle: invitation.document_title, + inviteUrl: buildGuestUrl(nextInvitation.token), + orderIndex: nextInvitation.order_index, + totalInvites: invitation.total_invites, + }); + } + + await mailService.sendExternalSigningOwnerNotificationEmail({ + ownerName: invitation.owner_name, + ownerEmail: invitation.owner_email, + inviteeName: invitation.invitee_name, + documentTitle: invitation.document_title, + completedInvites, + totalInvites: invitation.total_invites, + nextInviteeName: nextInvitation?.invitee_name || null, + isCompleted: !nextInvitation, + }); + + await auditService.logEvent({ + type: 'EXTERNAL_SIGNING_COMPLETED', + userId: invitation.owner_user_id, + email: invitation.invitee_email, + ip: context.ip, + userAgent: context.userAgent, + metadata: { + invitationId: invitation.id, + flowId: invitation.flow_id, + documentId: invitation.document_id, + orderIndex: invitation.order_index, + nextInvitationId: nextInvitation?.id || null, + }, + }); + + return { + message: 'Documento firmado correctamente.', + signedFilename: signedResult.signedFilename, + orderIndex: invitation.order_index, + totalInvites: invitation.total_invites, + nextInviteeName: nextInvitation?.invitee_name || null, + completed: !nextInvitation, + }; + } finally { + try { await fs.unlink(certificateFile.path); } catch {} + } +}; + +module.exports = { + createFlow, + getFlowForOwner, + addInviteesToFlow, + removeInvitation, + getPublicInvitation, + getPublicDocumentFile, + acceptTerms, + signInvitation, +}; diff --git a/backend/src/modules/invitations/invitations.validator.js b/backend/src/modules/invitations/invitations.validator.js new file mode 100644 index 0000000..6d8d7b5 --- /dev/null +++ b/backend/src/modules/invitations/invitations.validator.js @@ -0,0 +1,86 @@ +const Joi = require('joi'); + +const createInvitationsSchema = Joi.object({ + documentId: Joi.number().integer().positive().required(), + invitees: Joi.array() + .items( + Joi.object({ + name: Joi.string().trim().min(2).max(160).required(), + email: Joi.string().trim().email().required(), + }) + ) + .min(1) + .max(5) + .required(), +}); + +const addInviteesSchema = Joi.object({ + invitees: Joi.array() + .items( + Joi.object({ + name: Joi.string().trim().min(2).max(160).required(), + email: Joi.string().trim().email().required(), + }) + ) + .min(1) + .max(5) + .required(), +}); + +const validateGuestMultipart = (req, res, next) => { + const issues = []; + const { certPassword, page, rect, acceptedTerms } = req.body; + + if (!req.file) { + issues.push('Debes cargar un certificado P12 o PFX'); + } + + if (!certPassword) { + issues.push('La contraseña del certificado es obligatoria'); + } + + if (!['true', true, '1', 1].includes(acceptedTerms)) { + issues.push('Debes aceptar las condiciones para firmar'); + } + + if (page === undefined || Number.isNaN(Number(page))) { + issues.push('La página de firma es obligatoria'); + } + + if (!rect) { + issues.push('La posición de firma es obligatoria'); + } else { + try { + const parsed = typeof rect === 'string' ? JSON.parse(rect) : rect; + const valid = ['x', 'y', 'width', 'height'].every((key) => Number.isFinite(Number(parsed?.[key]))); + if (!valid) issues.push('La posición de firma es inválida'); + } catch { + issues.push('La posición de firma es inválida'); + } + } + + if (issues.length > 0) { + return res.status(400).json({ success: false, data: null, error: issues.join('; ') }); + } + + return next(); +}; + +const validate = (schema) => (req, res, next) => { + const { error } = schema.validate(req.body, { abortEarly: false }); + if (error) { + return res.status(400).json({ + success: false, + data: null, + error: error.details.map((item) => item.message).join('; '), + }); + } + return next(); +}; + +module.exports = { + validate, + createInvitationsSchema, + addInviteesSchema, + validateGuestMultipart, +}; diff --git a/backend/src/modules/mail/mail.repository.js b/backend/src/modules/mail/mail.repository.js index 58d220a..6d6dfbb 100644 --- a/backend/src/modules/mail/mail.repository.js +++ b/backend/src/modules/mail/mail.repository.js @@ -33,8 +33,12 @@ const getTransporter = () => { const sendMail = async ({ to, subject, html, text, attachments }) => { const activeTransporter = getTransporter(); + const from = env.mail.from.includes('<') + ? env.mail.from + : `LinkedSign <${env.mail.from}>`; const info = await activeTransporter.sendMail({ - from: env.mail.from, + from, + replyTo: env.mail.from, to, subject, html, diff --git a/backend/src/modules/mail/mail.service.js b/backend/src/modules/mail/mail.service.js index 045acfa..f410846 100644 --- a/backend/src/modules/mail/mail.service.js +++ b/backend/src/modules/mail/mail.service.js @@ -1,35 +1,75 @@ const env = require('../../config/env'); const mailRepository = require('./mail.repository'); +const escapeHtml = (value) => String(value ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + +const buildBaseUrl = () => env.frontendUrl.replace(/\/$/, ''); + +const supportEmail = () => env.mail.from; + +const renderEmailLayout = ({ preheader, title, intro, bodyHtml, ctaLabel, ctaUrl, footerNote }) => ` +
+ ${escapeHtml(preheader)} +
+
+
+
+
LinkedSign
+
Notificación transaccional del sistema
+
+
+

${escapeHtml(title)}

+

${intro}

+ ${bodyHtml} +
+ + ${escapeHtml(ctaLabel)} + +
+

+ Si el botón no funciona, copia y pega este enlace en tu navegador: +

+

+ ${ctaUrl} +

+ ${footerNote ? `

${footerNote}

` : ''} +
+
+ Mensaje enviado por LinkedSign desde ${escapeHtml(buildBaseUrl())}. Si necesitas ayuda, responde a ${escapeHtml(supportEmail())}. +
+
+
+`; + const sendVerificationEmail = async ({ name, email, verificationToken, attachments = [] }) => { - const verificationUrl = `${env.frontendUrl.replace(/\/$/, '')}/auth/verify-email/${verificationToken}`; - const subject = 'Confirma tu cuenta de LinkedSign'; + const verificationUrl = `${buildBaseUrl()}/auth/verify-email/${verificationToken}`; + const subject = 'Verifica tu cuenta de LinkedSign'; const text = [ `Hola ${name},`, '', - 'Confirma tu cuenta de LinkedSign usando este enlace:', + 'Para activar tu cuenta de LinkedSign, verifica tu correo usando este enlace:', verificationUrl, '', attachments.length > 0 ? 'Adjuntamos una copia de los documentos legales firmados durante tu registro.' : '', '', 'Si no solicitaste esta cuenta, puedes ignorar este mensaje.', ].filter(Boolean).join('\n'); - const html = ` -
-

Confirma tu cuenta de LinkedSign

-

Hola ${name},

-

Para activar tu cuenta, confirma tu correo electrónico haciendo clic en el siguiente botón:

-

- - Confirmar cuenta - -

-

Si el botón no funciona, copia y pega este enlace en tu navegador:

-

${verificationUrl}

- ${attachments.length > 0 ? '

Adjuntamos una copia de los documentos legales firmados durante tu registro inicial.

' : ''} -

Si no solicitaste esta cuenta, puedes ignorar este mensaje.

-
- `; + const html = renderEmailLayout({ + preheader: 'Verifica tu correo para activar tu cuenta de LinkedSign.', + title: 'Verifica tu cuenta', + intro: `Hola ${escapeHtml(name)}. Para activar tu cuenta de LinkedSign, verifica tu correo electrónico.`, + bodyHtml: attachments.length > 0 + ? '

Adjuntamos una copia de los documentos legales firmados durante tu registro inicial.

' + : '', + ctaLabel: 'Verificar cuenta', + ctaUrl: verificationUrl, + footerNote: 'Si no solicitaste esta cuenta, puedes ignorar este mensaje.', + }); const info = await mailRepository.sendMail({ to: email, @@ -62,12 +102,19 @@ const sendPasswordChangedEmail = async ({ name, email, changedAt }) => { 'Si no realizaste este cambio, contacta soporte inmediatamente.', ].join('\n'); const html = ` -
-

Contraseña actualizada

-

Hola ${name},

-

Te confirmamos que la contraseña de tu cuenta de LinkedSign fue actualizada.

-

Fecha: ${formattedDate}

-

Si no realizaste este cambio, contacta soporte inmediatamente.

+
+
+
+
LinkedSign
+
Notificación transaccional del sistema
+
+
+

Contraseña actualizada

+

Hola ${escapeHtml(name)}. Te confirmamos que la contraseña de tu cuenta fue actualizada.

+

Fecha: ${escapeHtml(formattedDate)}

+

Si no realizaste este cambio, responde a ${escapeHtml(supportEmail())}.

+
+
`; @@ -84,7 +131,7 @@ const sendPasswordChangedEmail = async ({ name, email, changedAt }) => { }; const sendPasswordChangeConfirmationEmail = async ({ name, email, confirmationToken }) => { - const confirmationUrl = `${env.frontendUrl.replace(/\/$/, '')}/auth/confirm-password-change/${confirmationToken}`; + const confirmationUrl = `${buildBaseUrl()}/auth/confirm-password-change/${confirmationToken}`; const subject = 'Confirma el cambio de contraseña de LinkedSign'; const text = [ `Hola ${name},`, @@ -95,22 +142,15 @@ const sendPasswordChangeConfirmationEmail = async ({ name, email, confirmationTo '', 'Si no realizaste esta solicitud, ignora este mensaje.', ].join('\n'); - const html = ` -
-

Confirma el cambio de contraseña

-

Hola ${name},

-

Recibimos una solicitud para cambiar la contraseña de tu cuenta de LinkedSign.

-

Para aplicar el cambio, confirma la solicitud usando este botón:

-

- - Confirmar cambio de contraseña - -

-

Si el botón no funciona, copia y pega este enlace en tu navegador:

-

${confirmationUrl}

-

Si no realizaste esta solicitud, ignora este mensaje.

-
- `; + const html = renderEmailLayout({ + preheader: 'Confirma el cambio de contraseña de tu cuenta de LinkedSign.', + title: 'Confirma el cambio de contraseña', + intro: `Hola ${escapeHtml(name)}. Recibimos una solicitud para cambiar la contraseña de tu cuenta de LinkedSign.`, + bodyHtml: '

Para aplicar el cambio, confirma esta solicitud.

', + ctaLabel: 'Confirmar cambio', + ctaUrl: confirmationUrl, + footerNote: 'Si no realizaste esta solicitud, puedes ignorar este mensaje.', + }); const info = await mailRepository.sendMail({ to: email, @@ -126,7 +166,7 @@ const sendPasswordChangeConfirmationEmail = async ({ name, email, confirmationTo }; const sendAccountLockedEmail = async ({ name, email, unlockToken }) => { - const unlockUrl = `${env.frontendUrl.replace(/\/$/, '')}/auth/unlock-account/${unlockToken}`; + const unlockUrl = `${buildBaseUrl()}/auth/unlock-account/${unlockToken}`; const subject = 'Tu cuenta de LinkedSign fue bloqueada temporalmente'; const text = [ `Hola ${name},`, @@ -138,23 +178,15 @@ const sendAccountLockedEmail = async ({ name, email, unlockToken }) => { 'El sistema te pedirá la contraseña de tu certificado de firma y luego solicitará una nueva contraseña de acceso.', 'Si no reconoces esta actividad, contacta soporte de inmediato.', ].join('\n'); - const html = ` -
-

Cuenta bloqueada temporalmente

-

Hola ${name},

-

Detectamos 3 intentos fallidos de acceso y tu cuenta fue bloqueada temporalmente por seguridad.

-

Para iniciar el desbloqueo, usa este botón:

-

- - Desbloquear sistema - -

-

Si el botón no funciona, copia y pega este enlace en tu navegador:

-

${unlockUrl}

-

El sistema te pedirá la contraseña de tu certificado de firma registrado y luego solicitará una nueva contraseña de acceso.

-

Si no reconoces esta actividad, contacta soporte de inmediato.

-
- `; + const html = renderEmailLayout({ + preheader: 'Tu cuenta fue bloqueada temporalmente por seguridad.', + title: 'Cuenta bloqueada temporalmente', + intro: `Hola ${escapeHtml(name)}. Detectamos varios intentos fallidos de acceso y bloqueamos tu cuenta de forma temporal por seguridad.`, + bodyHtml: '

Para iniciar el desbloqueo, el sistema te pedirá la contraseña de tu certificado registrado y luego solicitará una nueva contraseña de acceso.

', + ctaLabel: 'Iniciar desbloqueo', + ctaUrl: unlockUrl, + footerNote: 'Si no reconoces esta actividad, responde a este correo para revisión.', + }); const info = await mailRepository.sendMail({ to: email, @@ -169,9 +201,117 @@ const sendAccountLockedEmail = async ({ name, email, unlockToken }) => { }; }; +const sendExternalSigningInvitationEmail = async ({ + ownerName, + inviteeName, + inviteeEmail, + documentTitle, + inviteUrl, + orderIndex, + totalInvites, +}) => { + const subject = `Invitación de firma para ${documentTitle}`; + const text = [ + `Hola ${inviteeName},`, + '', + `${ownerName} te ha invitado a firmar el documento "${documentTitle}".`, + `Orden de firma: ${orderIndex} de ${totalInvites}.`, + '', + 'Importante:', + '- Debes usar tu propio certificado electrónico P12/PFX.', + '- Los datos de tu certificado y su contraseña no serán guardados por LinkedSign.', + '', + 'Abre este enlace seguro para revisar el documento y firmarlo:', + inviteUrl, + ].join('\n'); + + const html = renderEmailLayout({ + preheader: `Tienes una invitación de firma para ${documentTitle}.`, + title: 'Invitación de firma', + intro: `Hola ${escapeHtml(inviteeName)}. ${escapeHtml(ownerName)} te ha invitado a firmar el documento ${escapeHtml(documentTitle)}.`, + bodyHtml: ` +

Orden de firma: ${orderIndex} de ${totalInvites}

+

Para completar la firma:

+ + `, + ctaLabel: 'Revisar y firmar', + ctaUrl: inviteUrl, + footerNote: 'Si no esperabas esta invitación, puedes ignorar este mensaje.', + }); + + const info = await mailRepository.sendMail({ + to: inviteeEmail, + subject, + html, + text, + }); + + return { + inviteUrl, + messageId: info.messageId || null, + }; +}; + +const sendExternalSigningOwnerNotificationEmail = async ({ + ownerName, + ownerEmail, + inviteeName, + documentTitle, + completedInvites, + totalInvites, + nextInviteeName, + isCompleted, +}) => { + const subject = isCompleted + ? `Cadena de firmas completada: ${documentTitle}` + : `Nueva firma recibida: ${documentTitle}`; + const text = [ + `Hola ${ownerName},`, + '', + `${inviteeName} firmó correctamente el documento "${documentTitle}".`, + `Avance: ${completedInvites} de ${totalInvites} firmas externas completadas.`, + isCompleted + ? 'La cadena de firmas externas ha finalizado.' + : `El siguiente firmante en la lista es: ${nextInviteeName}.`, + ].join('\n'); + + const html = ` +
+
+
+
LinkedSign
+
Notificación transaccional del sistema
+
+
+

Actualización de firmas externas

+

Hola ${escapeHtml(ownerName)}. ${escapeHtml(inviteeName)} firmó correctamente el documento ${escapeHtml(documentTitle)}.

+

Avance: ${completedInvites} de ${totalInvites} firmas externas completadas.

+

${isCompleted ? 'La cadena de firmas externas ha finalizado.' : `El siguiente firmante en la lista es: ${escapeHtml(nextInviteeName)}.`}

+
+
+
+ `; + + const info = await mailRepository.sendMail({ + to: ownerEmail, + subject, + html, + text, + }); + + return { + messageId: info.messageId || null, + }; +}; + module.exports = { sendVerificationEmail, sendPasswordChangedEmail, sendPasswordChangeConfirmationEmail, sendAccountLockedEmail, + sendExternalSigningInvitationEmail, + sendExternalSigningOwnerNotificationEmail, }; diff --git a/backend/src/modules/signatures/signatures.repository.js b/backend/src/modules/signatures/signatures.repository.js index 383c561..976f004 100644 --- a/backend/src/modules/signatures/signatures.repository.js +++ b/backend/src/modules/signatures/signatures.repository.js @@ -71,6 +71,27 @@ const findByUser = async (userId) => { return rows; }; +const findLatestByDocumentAndUser = async (documentId, userId) => { + const [rows] = await db.execute( + `SELECT * + FROM signatures + WHERE document_id = ? AND user_id = ? + ORDER BY signed_at DESC, id DESC + LIMIT 1`, + [documentId, userId] + ); + return rows[0] || null; +}; + +const updateSignedArtifact = async (id, signedFilename, storagePath) => { + await db.execute( + `UPDATE signatures + SET signed_filename = ?, storage_path = ? + WHERE id = ?`, + [signedFilename, storagePath, id] + ); +}; + const saveVerificationRecord = async (code, data) => { const current = await readVerificationMap(); current[code] = data; @@ -107,6 +128,8 @@ module.exports = { findByDocument, findById, findByUser, + findLatestByDocumentAndUser, + updateSignedArtifact, saveVerificationRecord, findVerificationRecord, deleteVerificationRecordBySignatureId, diff --git a/backend/src/routes/index.js b/backend/src/routes/index.js index e5500d4..4a8dfca 100644 --- a/backend/src/routes/index.js +++ b/backend/src/routes/index.js @@ -4,6 +4,7 @@ const auditRoutes = require('../modules/audit/audit.routes'); const certificatesRoutes = require('../modules/certificates/certificates.routes'); const downloadsRoutes = require('../modules/downloads/downloads.routes'); const documentsRoutes = require('../modules/documents/documents.routes'); +const invitationsRoutes = require('../modules/invitations/invitations.routes'); const signaturesRoutes = require('../modules/signatures/signatures.routes'); const legalRoutes = require('../modules/legal/legal.routes'); const usersRoutes = require('../modules/users/users.routes'); @@ -16,6 +17,7 @@ router.use('/audit', auditRoutes); router.use('/certificates', certificatesRoutes); router.use('/downloads', downloadsRoutes); router.use('/documents', documentsRoutes); +router.use('/invitations', invitationsRoutes); router.use('/legal', legalRoutes); router.use('/signatures', signaturesRoutes); router.use('/users', usersRoutes); diff --git a/backend/src/server.js b/backend/src/server.js index c395150..f966300 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -1,9 +1,11 @@ const app = require('./app'); const env = require('./config/env'); const authRepository = require('./modules/auth/auth.repository'); +const invitationsRepository = require('./modules/invitations/invitations.repository'); const start = async () => { await authRepository.ensureSchema(); + await invitationsRepository.ensureSchema(); app.listen(env.port, () => { console.log(`LinkedSign backend running on port ${env.port}`); diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index e4d67fd..dd91aa2 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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() { } /> } /> } /> + } /> } /> } /> 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' }, + }); +}; diff --git a/frontend/src/index.css b/frontend/src/index.css index 12f766f..e41af8a 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -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; diff --git a/frontend/src/pages/documents/DocumentsPage.jsx b/frontend/src/pages/documents/DocumentsPage.jsx index 299fed4..fe599af 100644 --- a/frontend/src/pages/documents/DocumentsPage.jsx +++ b/frontend/src/pages/documents/DocumentsPage.jsx @@ -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 (
@@ -166,6 +267,173 @@ export default function DocumentsPage() {
)} + {invitationModalOpen && ( +
+
event.stopPropagation()}> +
+ Firmas externas +
+ +
+
+
+ {flowLoading ? ( +
+ ) : ( +
+
+

{selectedSignedDoc?.document_title || selectedSignedDoc?.signed_filename}

+
+
+ + Solo puedes invitar personas que dispongan de firma electrónica P12/PFX. +
+
+ + El sistema enviará automáticamente cada solicitud según el orden definido. +
+
+
+ + {flowData ? ( + <> + {canAddMoreInvitees && ( +
+

Agregar más firmantes externos

+
+
+ {invitees.map((invitee, index) => ( +
+
+ + updateInvitee(index, 'name', event.target.value)} + placeholder="Nombre completo" + required + /> +
+
+ + updateInvitee(index, 'email', event.target.value)} + placeholder="correo@dominio.com" + required + /> +
+ {invitees.length > 1 && ( + + )} +
+ ))} +
+
+ + +
+
+
+ )} + +
+

Orden actual de firmantes

+
+ {flowData.invitees?.map((invitee) => ( +
+
+ {invitee.order_index || invitee.orderIndex}. {invitee.invitee_name || invitee.inviteeName} + {invitee.invitee_email || invitee.inviteeEmail} +
+
+ + {invitee.status === 'queued' ? 'En espera' : invitee.status === 'sent' ? 'Enviado' : invitee.status === 'opened' ? 'Abierto' : invitee.status === 'signed' ? 'Firmado' : invitee.status} + + {invitee.status !== 'signed' && ( + + )} +
+
+ ))} +
+
+ + ) : ( +
+

Configurar firmantes externos

+
+
+ {invitees.map((invitee, index) => ( +
+
+ + updateInvitee(index, 'name', event.target.value)} + placeholder="Nombre completo" + required + /> +
+
+ + updateInvitee(index, 'email', event.target.value)} + placeholder="correo@dominio.com" + required + /> +
+ {invitees.length > 1 && ( + + )} +
+ ))} +
+
+ + +
+
+
+ )} +
+ )} +
+
+
+ )} +

Documentos

@@ -291,6 +559,9 @@ export default function DocumentsPage() { + diff --git a/frontend/src/pages/invitations/GuestInvitationPage.jsx b/frontend/src/pages/invitations/GuestInvitationPage.jsx new file mode 100644 index 0000000..7fd0b6d --- /dev/null +++ b/frontend/src/pages/invitations/GuestInvitationPage.jsx @@ -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 ( +
+
+
+
+
+ ); + } + + if (!invitation) { + return ( +
+
+

Invitación no disponible

+

El enlace no está activo o ya expiró.

+
+
+ ); + } + + return ( +
+ {showTermsModal && ( +
+
+
+ + Privacidad y uso de la firma invitada +
+

+ 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. +

+

+ Esta invitación requiere obligatoriamente firma electrónica con tu propio certificado P12/PFX. +

+
+ +
+
+
+ )} + +
+
+
+

Firma invitada

+

{invitation.documentTitle}

+
+
+ Firma {invitation.orderIndex} de {invitation.totalInvites} +
+
+ + {completedState ? ( +
+
+ +
+

Firma completada

+

La firma se registró correctamente y el usuario principal ya fue notificado.

+
+ Documento: {invitation.documentTitle} + Orden atendido: {completedState.orderIndex} de {completedState.totalInvites} + + Siguiente paso:{' '} + {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}.`} + +
+
+ ) : ( + <> +
+
+ + Documento: {invitation.documentTitle} +
+
+ + Debes usar tu propio certificado electrónico P12/PFX. +
+
+ + Los datos de tu certificado y su contraseña no serán guardados. +
+
+ + El estilo aplicado seguirá la configuración del usuario principal. +
+
+ Invitación válida hasta: {formatExpiry(invitation.tokenExpires)} +
+
+ + {pdfBlob && ( +
+

Ubica tu firma

+ +
+ )} + +
+

Completar firma con P12/PFX

+
+
+ + setCertificateFile(event.target.files?.[0] || null)} + /> + + Este certificado se usará solo para esta firma y no será guardado por el sistema. + +
+
+ + setCertPassword(event.target.value)} + placeholder="Contraseña del P12/PFX" + required + /> +
+
+ + setReason(event.target.value)} + placeholder="Firmado por invitación segura" + /> +
+
+ + setLocation(event.target.value)} + placeholder="Ciudad o lugar de firma" + /> +
+ +
+
+ + )} +
+
+ ); +} diff --git a/scripts/deploy_prod_web.sh b/scripts/deploy_prod_web.sh new file mode 100755 index 0000000..72ecbeb --- /dev/null +++ b/scripts/deploy_prod_web.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +set -euo pipefail + +SOURCE_ROOT="${SOURCE_ROOT:-/opt/apps/linkedsign/source}" +RUNTIME_ROOT="${RUNTIME_ROOT:-/opt/apps/linkedsign}" + +FRONTEND_SOURCE="$SOURCE_ROOT/frontend" +BACKEND_SOURCE="$SOURCE_ROOT/backend" +FRONTEND_RUNTIME="$RUNTIME_ROOT/frontend" +BACKEND_RUNTIME="$RUNTIME_ROOT/backend" + +echo "[deploy] Building frontend from $FRONTEND_SOURCE" +cd "$FRONTEND_SOURCE" +npm ci +npm run build + +echo "[deploy] Publishing frontend runtime to $FRONTEND_RUNTIME" +mkdir -p "$FRONTEND_RUNTIME" +find "$FRONTEND_RUNTIME" -mindepth 1 ! -name 'dist' ! -name 'server.js' -exec rm -rf {} + +rm -rf "$FRONTEND_RUNTIME/dist" +cp -r dist "$FRONTEND_RUNTIME/" +cp server.js "$FRONTEND_RUNTIME/" + +echo "[deploy] Publishing backend runtime to $BACKEND_RUNTIME" +mkdir -p "$BACKEND_RUNTIME" +find "$BACKEND_RUNTIME" -mindepth 1 ! -name '.env' -exec rm -rf {} + +cp -r "$BACKEND_SOURCE"/* "$BACKEND_RUNTIME/" + +cd "$BACKEND_RUNTIME" +npm ci --omit=dev + +echo "[deploy] Removing markdown files from production runtime" +find "$RUNTIME_ROOT" -type f -iname '*.md' -delete + +echo "[deploy] Re-applying hardened permissions" +chmod 750 "$RUNTIME_ROOT" "$RUNTIME_ROOT/backend" "$RUNTIME_ROOT/storage" "$RUNTIME_ROOT/logs" "$RUNTIME_ROOT/android" "$RUNTIME_ROOT/android/apk" 2>/dev/null || true +chmod 640 "$BACKEND_RUNTIME/.env" 2>/dev/null || true + +echo "[deploy] Done. Restart services manually:" +echo " sudo systemctl restart linkedsign-frontend" +echo " sudo systemctl restart linkedsign-backend"