Agregar firmas externas seguras y mejorar entregabilidad de correos
This commit is contained in:
@@ -43,3 +43,8 @@
|
|||||||
- En producción no debe quedar `android/` fuente; solo `/opt/apps/linkedsign/android/apk/LinkedSign-release.apk`.
|
- 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.
|
- 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 `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
|
||||||
|
|||||||
+16
-17
@@ -27,31 +27,30 @@
|
|||||||
- `git fetch origin main`
|
- `git fetch origin main`
|
||||||
- `git reset --hard 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`
|
- `rm -rf /opt/apps/linkedsign/source/android /opt/apps/linkedsign/source/android_react /opt/apps/linkedsign/source/android_firma_electronica.html`
|
||||||
8. Recompila frontend:
|
8. En producción usa el script seguro:
|
||||||
- `cd /opt/apps/linkedsign/source/frontend`
|
- `cd /opt/apps/linkedsign/source`
|
||||||
- `npm ci`
|
- `bash scripts/deploy_prod_web.sh`
|
||||||
- `npm run build`
|
9. Reinicia servicios:
|
||||||
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:
|
|
||||||
- `sudo systemctl restart linkedsign-frontend`
|
- `sudo systemctl restart linkedsign-frontend`
|
||||||
- `sudo systemctl restart linkedsign-backend`
|
- `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`
|
- `find /opt/apps/linkedsign -type f -iname '*.md' -delete`
|
||||||
13. Verifica:
|
11. Verifica:
|
||||||
- `curl -I http://127.0.0.1:3000`
|
- `curl -I http://127.0.0.1:3000`
|
||||||
- `curl http://127.0.0.1:4000/health`
|
- `curl http://127.0.0.1:4000/health`
|
||||||
|
|
||||||
## Notas
|
## 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`.
|
- 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/`.
|
- 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.
|
- 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
|
||||||
|
|||||||
@@ -167,4 +167,12 @@ const loadForSigning = async (certId, userId, password) => {
|
|||||||
return { privateKey, certificate };
|
return { privateKey, certificate };
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = { upload, list, getOne, remove, loadForSigning, getLatestActiveForUser };
|
module.exports = {
|
||||||
|
upload,
|
||||||
|
list,
|
||||||
|
getOne,
|
||||||
|
remove,
|
||||||
|
loadForSigning,
|
||||||
|
getLatestActiveForUser,
|
||||||
|
parseCertificate,
|
||||||
|
};
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -33,8 +33,12 @@ const getTransporter = () => {
|
|||||||
|
|
||||||
const sendMail = async ({ to, subject, html, text, attachments }) => {
|
const sendMail = async ({ to, subject, html, text, attachments }) => {
|
||||||
const activeTransporter = getTransporter();
|
const activeTransporter = getTransporter();
|
||||||
|
const from = env.mail.from.includes('<')
|
||||||
|
? env.mail.from
|
||||||
|
: `LinkedSign <${env.mail.from}>`;
|
||||||
const info = await activeTransporter.sendMail({
|
const info = await activeTransporter.sendMail({
|
||||||
from: env.mail.from,
|
from,
|
||||||
|
replyTo: env.mail.from,
|
||||||
to,
|
to,
|
||||||
subject,
|
subject,
|
||||||
html,
|
html,
|
||||||
|
|||||||
@@ -1,35 +1,75 @@
|
|||||||
const env = require('../../config/env');
|
const env = require('../../config/env');
|
||||||
const mailRepository = require('./mail.repository');
|
const mailRepository = require('./mail.repository');
|
||||||
|
|
||||||
|
const escapeHtml = (value) => String(value ?? '')
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.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 }) => `
|
||||||
|
<div style="display:none;max-height:0;overflow:hidden;opacity:0;color:transparent;">
|
||||||
|
${escapeHtml(preheader)}
|
||||||
|
</div>
|
||||||
|
<div style="margin:0;padding:24px;background:#f3f6fb;font-family:Arial,sans-serif;color:#14213d;">
|
||||||
|
<div style="max-width:620px;margin:0 auto;background:#ffffff;border:1px solid #d9e2f0;border-radius:14px;overflow:hidden;">
|
||||||
|
<div style="padding:18px 24px;border-bottom:1px solid #e5ecf5;background:#f8fbff;">
|
||||||
|
<div style="font-size:18px;font-weight:700;color:#1d4ed8;">LinkedSign</div>
|
||||||
|
<div style="font-size:12px;color:#64748b;margin-top:4px;">Notificación transaccional del sistema</div>
|
||||||
|
</div>
|
||||||
|
<div style="padding:24px;">
|
||||||
|
<h2 style="margin:0 0 14px;font-size:22px;line-height:1.3;color:#0f172a;">${escapeHtml(title)}</h2>
|
||||||
|
<p style="margin:0 0 16px;font-size:14px;line-height:1.6;color:#334155;">${intro}</p>
|
||||||
|
${bodyHtml}
|
||||||
|
<div style="margin:24px 0 18px;">
|
||||||
|
<a href="${ctaUrl}" style="background:#2563eb;color:#ffffff;text-decoration:none;padding:12px 18px;border-radius:10px;display:inline-block;font-size:14px;font-weight:700;">
|
||||||
|
${escapeHtml(ctaLabel)}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<p style="margin:0 0 10px;font-size:12px;line-height:1.6;color:#64748b;">
|
||||||
|
Si el botón no funciona, copia y pega este enlace en tu navegador:
|
||||||
|
</p>
|
||||||
|
<p style="margin:0 0 18px;font-size:12px;word-break:break-all;">
|
||||||
|
<a href="${ctaUrl}" style="color:#2563eb;text-decoration:none;">${ctaUrl}</a>
|
||||||
|
</p>
|
||||||
|
${footerNote ? `<p style="margin:0;font-size:12px;line-height:1.6;color:#64748b;">${footerNote}</p>` : ''}
|
||||||
|
</div>
|
||||||
|
<div style="padding:14px 24px;border-top:1px solid #e5ecf5;background:#fafcff;font-size:11px;line-height:1.6;color:#64748b;">
|
||||||
|
Mensaje enviado por LinkedSign desde ${escapeHtml(buildBaseUrl())}. Si necesitas ayuda, responde a ${escapeHtml(supportEmail())}.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
const sendVerificationEmail = async ({ name, email, verificationToken, attachments = [] }) => {
|
const sendVerificationEmail = async ({ name, email, verificationToken, attachments = [] }) => {
|
||||||
const verificationUrl = `${env.frontendUrl.replace(/\/$/, '')}/auth/verify-email/${verificationToken}`;
|
const verificationUrl = `${buildBaseUrl()}/auth/verify-email/${verificationToken}`;
|
||||||
const subject = 'Confirma tu cuenta de LinkedSign';
|
const subject = 'Verifica tu cuenta de LinkedSign';
|
||||||
const text = [
|
const text = [
|
||||||
`Hola ${name},`,
|
`Hola ${name},`,
|
||||||
'',
|
'',
|
||||||
'Confirma tu cuenta de LinkedSign usando este enlace:',
|
'Para activar tu cuenta de LinkedSign, verifica tu correo usando este enlace:',
|
||||||
verificationUrl,
|
verificationUrl,
|
||||||
'',
|
'',
|
||||||
attachments.length > 0 ? 'Adjuntamos una copia de los documentos legales firmados durante tu registro.' : '',
|
attachments.length > 0 ? 'Adjuntamos una copia de los documentos legales firmados durante tu registro.' : '',
|
||||||
'',
|
'',
|
||||||
'Si no solicitaste esta cuenta, puedes ignorar este mensaje.',
|
'Si no solicitaste esta cuenta, puedes ignorar este mensaje.',
|
||||||
].filter(Boolean).join('\n');
|
].filter(Boolean).join('\n');
|
||||||
const html = `
|
const html = renderEmailLayout({
|
||||||
<div style="font-family: Arial, sans-serif; line-height: 1.5; color: #111827;">
|
preheader: 'Verifica tu correo para activar tu cuenta de LinkedSign.',
|
||||||
<h2 style="margin-bottom: 12px;">Confirma tu cuenta de LinkedSign</h2>
|
title: 'Verifica tu cuenta',
|
||||||
<p>Hola ${name},</p>
|
intro: `Hola ${escapeHtml(name)}. Para activar tu cuenta de LinkedSign, verifica tu correo electrónico.`,
|
||||||
<p>Para activar tu cuenta, confirma tu correo electrónico haciendo clic en el siguiente botón:</p>
|
bodyHtml: attachments.length > 0
|
||||||
<p style="margin: 24px 0;">
|
? '<p style="margin:0 0 14px;font-size:14px;line-height:1.6;color:#334155;">Adjuntamos una copia de los documentos legales firmados durante tu registro inicial.</p>'
|
||||||
<a href="${verificationUrl}" style="background: #4f46e5; color: #ffffff; text-decoration: none; padding: 12px 18px; border-radius: 8px; display: inline-block; font-weight: 600;">
|
: '',
|
||||||
Confirmar cuenta
|
ctaLabel: 'Verificar cuenta',
|
||||||
</a>
|
ctaUrl: verificationUrl,
|
||||||
</p>
|
footerNote: 'Si no solicitaste esta cuenta, puedes ignorar este mensaje.',
|
||||||
<p>Si el botón no funciona, copia y pega este enlace en tu navegador:</p>
|
});
|
||||||
<p><a href="${verificationUrl}">${verificationUrl}</a></p>
|
|
||||||
${attachments.length > 0 ? '<p>Adjuntamos una copia de los documentos legales firmados durante tu registro inicial.</p>' : ''}
|
|
||||||
<p>Si no solicitaste esta cuenta, puedes ignorar este mensaje.</p>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
const info = await mailRepository.sendMail({
|
const info = await mailRepository.sendMail({
|
||||||
to: email,
|
to: email,
|
||||||
@@ -62,12 +102,19 @@ const sendPasswordChangedEmail = async ({ name, email, changedAt }) => {
|
|||||||
'Si no realizaste este cambio, contacta soporte inmediatamente.',
|
'Si no realizaste este cambio, contacta soporte inmediatamente.',
|
||||||
].join('\n');
|
].join('\n');
|
||||||
const html = `
|
const html = `
|
||||||
<div style="font-family: Arial, sans-serif; line-height: 1.5; color: #111827;">
|
<div style="margin:0;padding:24px;background:#f3f6fb;font-family:Arial,sans-serif;color:#14213d;">
|
||||||
<h2 style="margin-bottom: 12px;">Contraseña actualizada</h2>
|
<div style="max-width:620px;margin:0 auto;background:#ffffff;border:1px solid #d9e2f0;border-radius:14px;overflow:hidden;">
|
||||||
<p>Hola ${name},</p>
|
<div style="padding:18px 24px;border-bottom:1px solid #e5ecf5;background:#f8fbff;">
|
||||||
<p>Te confirmamos que la contraseña de tu cuenta de LinkedSign fue actualizada.</p>
|
<div style="font-size:18px;font-weight:700;color:#1d4ed8;">LinkedSign</div>
|
||||||
<p><strong>Fecha:</strong> ${formattedDate}</p>
|
<div style="font-size:12px;color:#64748b;margin-top:4px;">Notificación transaccional del sistema</div>
|
||||||
<p>Si no realizaste este cambio, contacta soporte inmediatamente.</p>
|
</div>
|
||||||
|
<div style="padding:24px;">
|
||||||
|
<h2 style="margin:0 0 14px;font-size:22px;line-height:1.3;color:#0f172a;">Contraseña actualizada</h2>
|
||||||
|
<p style="margin:0 0 16px;font-size:14px;line-height:1.6;color:#334155;">Hola ${escapeHtml(name)}. Te confirmamos que la contraseña de tu cuenta fue actualizada.</p>
|
||||||
|
<p style="margin:0 0 18px;font-size:14px;line-height:1.6;color:#334155;"><strong>Fecha:</strong> ${escapeHtml(formattedDate)}</p>
|
||||||
|
<p style="margin:0;font-size:12px;line-height:1.6;color:#64748b;">Si no realizaste este cambio, responde a ${escapeHtml(supportEmail())}.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -84,7 +131,7 @@ const sendPasswordChangedEmail = async ({ name, email, changedAt }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const sendPasswordChangeConfirmationEmail = async ({ name, email, confirmationToken }) => {
|
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 subject = 'Confirma el cambio de contraseña de LinkedSign';
|
||||||
const text = [
|
const text = [
|
||||||
`Hola ${name},`,
|
`Hola ${name},`,
|
||||||
@@ -95,22 +142,15 @@ const sendPasswordChangeConfirmationEmail = async ({ name, email, confirmationTo
|
|||||||
'',
|
'',
|
||||||
'Si no realizaste esta solicitud, ignora este mensaje.',
|
'Si no realizaste esta solicitud, ignora este mensaje.',
|
||||||
].join('\n');
|
].join('\n');
|
||||||
const html = `
|
const html = renderEmailLayout({
|
||||||
<div style="font-family: Arial, sans-serif; line-height: 1.5; color: #111827;">
|
preheader: 'Confirma el cambio de contraseña de tu cuenta de LinkedSign.',
|
||||||
<h2 style="margin-bottom: 12px;">Confirma el cambio de contraseña</h2>
|
title: 'Confirma el cambio de contraseña',
|
||||||
<p>Hola ${name},</p>
|
intro: `Hola ${escapeHtml(name)}. Recibimos una solicitud para cambiar la contraseña de tu cuenta de LinkedSign.`,
|
||||||
<p>Recibimos una solicitud para cambiar la contraseña de tu cuenta de LinkedSign.</p>
|
bodyHtml: '<p style="margin:0 0 14px;font-size:14px;line-height:1.6;color:#334155;">Para aplicar el cambio, confirma esta solicitud.</p>',
|
||||||
<p>Para aplicar el cambio, confirma la solicitud usando este botón:</p>
|
ctaLabel: 'Confirmar cambio',
|
||||||
<p style="margin: 24px 0;">
|
ctaUrl: confirmationUrl,
|
||||||
<a href="${confirmationUrl}" style="background: #4f46e5; color: #ffffff; text-decoration: none; padding: 12px 18px; border-radius: 8px; display: inline-block; font-weight: 600;">
|
footerNote: 'Si no realizaste esta solicitud, puedes ignorar este mensaje.',
|
||||||
Confirmar cambio de contraseña
|
});
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
<p>Si el botón no funciona, copia y pega este enlace en tu navegador:</p>
|
|
||||||
<p><a href="${confirmationUrl}">${confirmationUrl}</a></p>
|
|
||||||
<p>Si no realizaste esta solicitud, ignora este mensaje.</p>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
const info = await mailRepository.sendMail({
|
const info = await mailRepository.sendMail({
|
||||||
to: email,
|
to: email,
|
||||||
@@ -126,7 +166,7 @@ const sendPasswordChangeConfirmationEmail = async ({ name, email, confirmationTo
|
|||||||
};
|
};
|
||||||
|
|
||||||
const sendAccountLockedEmail = async ({ name, email, unlockToken }) => {
|
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 subject = 'Tu cuenta de LinkedSign fue bloqueada temporalmente';
|
||||||
const text = [
|
const text = [
|
||||||
`Hola ${name},`,
|
`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.',
|
'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.',
|
'Si no reconoces esta actividad, contacta soporte de inmediato.',
|
||||||
].join('\n');
|
].join('\n');
|
||||||
const html = `
|
const html = renderEmailLayout({
|
||||||
<div style="font-family: Arial, sans-serif; line-height: 1.5; color: #111827;">
|
preheader: 'Tu cuenta fue bloqueada temporalmente por seguridad.',
|
||||||
<h2 style="margin-bottom: 12px;">Cuenta bloqueada temporalmente</h2>
|
title: 'Cuenta bloqueada temporalmente',
|
||||||
<p>Hola ${name},</p>
|
intro: `Hola ${escapeHtml(name)}. Detectamos varios intentos fallidos de acceso y bloqueamos tu cuenta de forma temporal por seguridad.`,
|
||||||
<p>Detectamos 3 intentos fallidos de acceso y tu cuenta fue bloqueada temporalmente por seguridad.</p>
|
bodyHtml: '<p style="margin:0 0 14px;font-size:14px;line-height:1.6;color:#334155;">Para iniciar el desbloqueo, el sistema te pedirá la contraseña de tu certificado registrado y luego solicitará una nueva contraseña de acceso.</p>',
|
||||||
<p>Para iniciar el desbloqueo, usa este botón:</p>
|
ctaLabel: 'Iniciar desbloqueo',
|
||||||
<p style="margin: 24px 0;">
|
ctaUrl: unlockUrl,
|
||||||
<a href="${unlockUrl}" style="background: #c62828; color: #ffffff; text-decoration: none; padding: 12px 18px; border-radius: 8px; display: inline-block; font-weight: 600;">
|
footerNote: 'Si no reconoces esta actividad, responde a este correo para revisión.',
|
||||||
Desbloquear sistema
|
});
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
<p>Si el botón no funciona, copia y pega este enlace en tu navegador:</p>
|
|
||||||
<p><a href="${unlockUrl}">${unlockUrl}</a></p>
|
|
||||||
<p>El sistema te pedirá la contraseña de tu certificado de firma registrado y luego solicitará una nueva contraseña de acceso.</p>
|
|
||||||
<p>Si no reconoces esta actividad, contacta soporte de inmediato.</p>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
const info = await mailRepository.sendMail({
|
const info = await mailRepository.sendMail({
|
||||||
to: email,
|
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: `
|
||||||
|
<p style="margin:0 0 14px;font-size:14px;line-height:1.6;color:#334155;"><strong>Orden de firma:</strong> ${orderIndex} de ${totalInvites}</p>
|
||||||
|
<p style="margin:0 0 8px;font-size:14px;line-height:1.6;color:#334155;">Para completar la firma:</p>
|
||||||
|
<ul style="margin:0 0 14px 18px;padding:0;color:#334155;font-size:14px;line-height:1.6;">
|
||||||
|
<li>Debes usar tu propio certificado electrónico P12/PFX.</li>
|
||||||
|
<li>Los datos del certificado y su contraseña no serán guardados por LinkedSign.</li>
|
||||||
|
</ul>
|
||||||
|
`,
|
||||||
|
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 = `
|
||||||
|
<div style="margin:0;padding:24px;background:#f3f6fb;font-family:Arial,sans-serif;color:#14213d;">
|
||||||
|
<div style="max-width:620px;margin:0 auto;background:#ffffff;border:1px solid #d9e2f0;border-radius:14px;overflow:hidden;">
|
||||||
|
<div style="padding:18px 24px;border-bottom:1px solid #e5ecf5;background:#f8fbff;">
|
||||||
|
<div style="font-size:18px;font-weight:700;color:#1d4ed8;">LinkedSign</div>
|
||||||
|
<div style="font-size:12px;color:#64748b;margin-top:4px;">Notificación transaccional del sistema</div>
|
||||||
|
</div>
|
||||||
|
<div style="padding:24px;">
|
||||||
|
<h2 style="margin:0 0 14px;font-size:22px;line-height:1.3;color:#0f172a;">Actualización de firmas externas</h2>
|
||||||
|
<p style="margin:0 0 14px;font-size:14px;line-height:1.6;color:#334155;">Hola ${escapeHtml(ownerName)}. <strong>${escapeHtml(inviteeName)}</strong> firmó correctamente el documento <strong>${escapeHtml(documentTitle)}</strong>.</p>
|
||||||
|
<p style="margin:0 0 14px;font-size:14px;line-height:1.6;color:#334155;"><strong>Avance:</strong> ${completedInvites} de ${totalInvites} firmas externas completadas.</p>
|
||||||
|
<p style="margin:0;font-size:14px;line-height:1.6;color:#334155;">${isCompleted ? 'La cadena de firmas externas ha finalizado.' : `El siguiente firmante en la lista es: <strong>${escapeHtml(nextInviteeName)}</strong>.`}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const info = await mailRepository.sendMail({
|
||||||
|
to: ownerEmail,
|
||||||
|
subject,
|
||||||
|
html,
|
||||||
|
text,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
messageId: info.messageId || null,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
sendVerificationEmail,
|
sendVerificationEmail,
|
||||||
sendPasswordChangedEmail,
|
sendPasswordChangedEmail,
|
||||||
sendPasswordChangeConfirmationEmail,
|
sendPasswordChangeConfirmationEmail,
|
||||||
sendAccountLockedEmail,
|
sendAccountLockedEmail,
|
||||||
|
sendExternalSigningInvitationEmail,
|
||||||
|
sendExternalSigningOwnerNotificationEmail,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -71,6 +71,27 @@ const findByUser = async (userId) => {
|
|||||||
return rows;
|
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 saveVerificationRecord = async (code, data) => {
|
||||||
const current = await readVerificationMap();
|
const current = await readVerificationMap();
|
||||||
current[code] = data;
|
current[code] = data;
|
||||||
@@ -107,6 +128,8 @@ module.exports = {
|
|||||||
findByDocument,
|
findByDocument,
|
||||||
findById,
|
findById,
|
||||||
findByUser,
|
findByUser,
|
||||||
|
findLatestByDocumentAndUser,
|
||||||
|
updateSignedArtifact,
|
||||||
saveVerificationRecord,
|
saveVerificationRecord,
|
||||||
findVerificationRecord,
|
findVerificationRecord,
|
||||||
deleteVerificationRecordBySignatureId,
|
deleteVerificationRecordBySignatureId,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ const auditRoutes = require('../modules/audit/audit.routes');
|
|||||||
const certificatesRoutes = require('../modules/certificates/certificates.routes');
|
const certificatesRoutes = require('../modules/certificates/certificates.routes');
|
||||||
const downloadsRoutes = require('../modules/downloads/downloads.routes');
|
const downloadsRoutes = require('../modules/downloads/downloads.routes');
|
||||||
const documentsRoutes = require('../modules/documents/documents.routes');
|
const documentsRoutes = require('../modules/documents/documents.routes');
|
||||||
|
const invitationsRoutes = require('../modules/invitations/invitations.routes');
|
||||||
const signaturesRoutes = require('../modules/signatures/signatures.routes');
|
const signaturesRoutes = require('../modules/signatures/signatures.routes');
|
||||||
const legalRoutes = require('../modules/legal/legal.routes');
|
const legalRoutes = require('../modules/legal/legal.routes');
|
||||||
const usersRoutes = require('../modules/users/users.routes');
|
const usersRoutes = require('../modules/users/users.routes');
|
||||||
@@ -16,6 +17,7 @@ router.use('/audit', auditRoutes);
|
|||||||
router.use('/certificates', certificatesRoutes);
|
router.use('/certificates', certificatesRoutes);
|
||||||
router.use('/downloads', downloadsRoutes);
|
router.use('/downloads', downloadsRoutes);
|
||||||
router.use('/documents', documentsRoutes);
|
router.use('/documents', documentsRoutes);
|
||||||
|
router.use('/invitations', invitationsRoutes);
|
||||||
router.use('/legal', legalRoutes);
|
router.use('/legal', legalRoutes);
|
||||||
router.use('/signatures', signaturesRoutes);
|
router.use('/signatures', signaturesRoutes);
|
||||||
router.use('/users', usersRoutes);
|
router.use('/users', usersRoutes);
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
const app = require('./app');
|
const app = require('./app');
|
||||||
const env = require('./config/env');
|
const env = require('./config/env');
|
||||||
const authRepository = require('./modules/auth/auth.repository');
|
const authRepository = require('./modules/auth/auth.repository');
|
||||||
|
const invitationsRepository = require('./modules/invitations/invitations.repository');
|
||||||
|
|
||||||
const start = async () => {
|
const start = async () => {
|
||||||
await authRepository.ensureSchema();
|
await authRepository.ensureSchema();
|
||||||
|
await invitationsRepository.ensureSchema();
|
||||||
|
|
||||||
app.listen(env.port, () => {
|
app.listen(env.port, () => {
|
||||||
console.log(`LinkedSign backend running on port ${env.port}`);
|
console.log(`LinkedSign backend running on port ${env.port}`);
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import SignaturesPage from './pages/signatures/SignaturesPage';
|
|||||||
import VerifySignaturePage from './pages/verify/VerifySignaturePage';
|
import VerifySignaturePage from './pages/verify/VerifySignaturePage';
|
||||||
import LegalPage from './pages/legal/LegalPage';
|
import LegalPage from './pages/legal/LegalPage';
|
||||||
import AuditPage from './pages/audit/AuditPage';
|
import AuditPage from './pages/audit/AuditPage';
|
||||||
|
import GuestInvitationPage from './pages/invitations/GuestInvitationPage';
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
@@ -28,6 +29,7 @@ export default function App() {
|
|||||||
<Route path="/auth/verify-email/:token" element={<VerifyEmailPage />} />
|
<Route path="/auth/verify-email/:token" element={<VerifyEmailPage />} />
|
||||||
<Route path="/auth/confirm-password-change/:token" element={<ConfirmPasswordChangePage />} />
|
<Route path="/auth/confirm-password-change/:token" element={<ConfirmPasswordChangePage />} />
|
||||||
<Route path="/auth/unlock-account/:token" element={<UnlockAccountPage />} />
|
<Route path="/auth/unlock-account/:token" element={<UnlockAccountPage />} />
|
||||||
|
<Route path="/guest-sign/:token" element={<GuestInvitationPage />} />
|
||||||
<Route path="/verify/:code" element={<VerifySignaturePage />} />
|
<Route path="/verify/:code" element={<VerifySignaturePage />} />
|
||||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||||
<Route
|
<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);
|
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 ─────────────────────────────────────────────── */
|
/* ─── Responsive ─────────────────────────────────────────────── */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.app-layout {
|
.app-layout {
|
||||||
@@ -2185,6 +2352,17 @@ select {
|
|||||||
flex-wrap: wrap;
|
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,
|
.style-card-inline-tools,
|
||||||
.image-upload-area {
|
.image-upload-area {
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import { useEffect, useState, useRef } from 'react';
|
|||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { listDocuments, uploadDocument, deleteDocument, downloadDocument } from '../../api/documents';
|
import { listDocuments, uploadDocument, deleteDocument, downloadDocument } from '../../api/documents';
|
||||||
import { listSignedDocuments, downloadSigned } from '../../api/signatures';
|
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 { Document, Page, pdfjs } from 'react-pdf';
|
||||||
import 'react-pdf/dist/Page/AnnotationLayer.css';
|
import 'react-pdf/dist/Page/AnnotationLayer.css';
|
||||||
import 'react-pdf/dist/Page/TextLayer.css';
|
import 'react-pdf/dist/Page/TextLayer.css';
|
||||||
@@ -39,6 +40,13 @@ export default function DocumentsPage() {
|
|||||||
const [numPages, setNumPages] = useState(null);
|
const [numPages, setNumPages] = useState(null);
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const [loadingViewer, setLoadingViewer] = useState(false);
|
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 = () => {
|
const loadDocs = () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -127,6 +135,99 @@ export default function DocumentsPage() {
|
|||||||
|
|
||||||
const closeViewer = () => { setViewerBlob(null); setViewerTitle(''); };
|
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 (
|
return (
|
||||||
<div className="page">
|
<div className="page">
|
||||||
|
|
||||||
@@ -166,6 +267,173 @@ export default function DocumentsPage() {
|
|||||||
</div>
|
</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 className="page-header">
|
||||||
<div>
|
<div>
|
||||||
<h2>Documentos</h2>
|
<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')}>
|
<button className="btn-icon" title="Ver PDF firmado" onClick={() => handleView(doc.id, doc.signed_filename, 'signed')}>
|
||||||
<Eye size={15} />
|
<Eye size={15} />
|
||||||
</button>
|
</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)}>
|
<button className="btn-icon" title="Descargar PDF firmado" onClick={() => handleDownloadSigned(doc.id, doc.signed_filename)}>
|
||||||
<Download size={15} />
|
<Download size={15} />
|
||||||
</button>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Executable
+41
@@ -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"
|
||||||
Reference in New Issue
Block a user