Compare commits

..

62 Commits

Author SHA1 Message Date
Milton Gonzalez 8d17393d5f Remove FirmaEC debug markers and document backend restart 2026-05-22 16:59:05 -05:00
Milton Gonzalez 848441d593 Add FirmaEC PDF render trace log in 1.2.34 2026-05-22 16:39:46 -05:00
Milton Gonzalez 99a5350008 Fix FirmaEC stamp preview borderless layout and bump to 1.2.33 2026-05-22 16:31:28 -05:00
Milton Gonzalez 82f3d2cbf9 Add control marker to both FirmaEC PDF header lines in 1.2.32 2026-05-22 15:26:58 -05:00
Milton Gonzalez ff7ff92d84 Move FirmaEC control marker to Firmado line in 1.2.31 2026-05-22 15:24:30 -05:00
Milton Gonzalez b6c169ffbb Add FirmaEC control marker and bump to 1.2.30 2026-05-22 15:22:14 -05:00
Milton Gonzalez 8d090dc8ca Increase FirmaEC name size and bump to 1.2.29 2026-05-22 15:13:49 -05:00
Milton Gonzalez 7fc4707724 Add FirmaEC PDF marker text and bump to 1.2.28 2026-05-22 14:55:37 -05:00
Milton Gonzalez bc28441531 Split FirmaEC names after second word for 4+ words in 1.2.27 2026-05-22 14:46:10 -05:00
Milton Gonzalez 3c9aefa10e Show app version in sidebar and split 4-word FirmaEC names in 1.2.26 2026-05-22 14:31:10 -05:00
Milton Gonzalez 81bd8d3c59 Use stored signature preferences during signing and bump to 1.2.25 2026-05-22 14:19:27 -05:00
Milton Gonzalez fdde9cdb5d Use invisible signature widget for borderless FirmaEC and bump to 1.2.24 2026-05-22 14:10:43 -05:00
Milton Gonzalez 5ed70766b9 Hide signature widget border for FirmaEC and bump to 1.2.23 2026-05-22 14:02:56 -05:00
Milton Gonzalez 2a0f272af1 Fix borderless FirmaEC rendering and bump to 1.2.22 2026-05-22 12:02:44 -05:00
Milton Gonzalez e67e7aa122 Honor borderless FirmaEC signatures 2026-05-22 11:50:08 -05:00
Milton Gonzalez f19f8bc899 Fallback unknown signature styles when saving preferences 2026-05-21 22:43:10 -05:00
Milton Gonzalez dfcc0ca37b Normalize FirmaEC signature style values 2026-05-21 22:34:16 -05:00
Milton Gonzalez 8308e3537e Relax signature preference validation for FirmaEC 2026-05-21 22:28:44 -05:00
Milton Gonzalez 8bc244855c Add FirmaEC signature style and bump to 1.2.17 2026-05-21 22:07:22 -05:00
Milton Gonzalez 5d7d610322 Update sidebar brand subtitle 2026-04-27 16:56:33 -05:00
Milton Gonzalez 92d934bb36 Refine sign now spacing and bump to 1.2.16 2026-04-27 16:06:33 -05:00
Milton Gonzalez 58ab0311df Increase sign now spacing and bump to 1.2.15 2026-04-27 15:15:33 -05:00
Milton Gonzalez 199e62e641 Bump version to 1.2.14 2026-04-27 15:09:28 -05:00
Milton Gonzalez 505bfa0efd Adjust spacing in sign now menu 2026-04-27 15:03:46 -05:00
Milton Gonzalez 55837a2982 Hide advanced signing options by default 2026-04-27 13:18:49 -05:00
Milton Gonzalez b9f5165e38 Reorder web navigation items 2026-04-23 22:34:09 -05:00
Milton Gonzalez df7d07459a Default signing location to Ecuador 2026-04-23 22:26:25 -05:00
Milton Gonzalez fa5cd053f6 Show local upload option in signing selector 2026-04-23 22:07:51 -05:00
Milton Gonzalez 24fe8d0a0b Improve web signing flow and locale handling 2026-04-23 21:45:24 -05:00
Milton Gonzalez b24d17b98e Fix audit service circular dependency 2026-04-23 21:12:48 -05:00
Milton Gonzalez 892fb31c18 Simplify Android biometric screen 2026-04-23 15:45:10 -05:00
Milton Gonzalez 14a3d9f497 Relax Android biometric availability check 2026-04-23 15:30:11 -05:00
Milton Gonzalez 51b2116bde Keep Android biometric opt-in available 2026-04-23 15:28:34 -05:00
Milton Gonzalez d80d537d02 Lock Android app on background for biometrics 2026-04-23 15:07:16 -05:00
Milton Gonzalez 5f27b1562e Fix web version injection 2026-04-23 15:01:46 -05:00
Milton Gonzalez df123e2cf4 Fix Android biometric logout flow 2026-04-23 14:37:52 -05:00
Milton Gonzalez a96907b684 Fix production web API routing 2026-04-23 14:29:47 -05:00
Milton Gonzalez 51074d08c6 Restore Android biometric opt-in prompt 2026-04-23 14:14:21 -05:00
Milton Gonzalez 1b930e1737 Add first-login About flow to Android 2026-04-23 14:11:36 -05:00
Milton Gonzalez eb3f47423f Ignore local Gitea credentials file 2026-04-23 13:33:40 -05:00
Milton Gonzalez 8194c4dee4 Persist biometric access 2026-04-23 10:54:19 -05:00
Milton Gonzalez ae5649f408 Adjust Android document actions 2026-04-23 10:37:04 -05:00
Milton Gonzalez 622048b163 Fix Android documents and idle session 2026-04-23 10:12:00 -05:00
Milton Gonzalez 9e0fbcaa0d Hide Android system navigation 2026-04-23 09:21:30 -05:00
Milton Gonzalez 2cbd0f2ca3 Add Android about screen 2026-04-23 08:54:13 -05:00
Milton Gonzalez 62cb604412 Show app version on login 2026-04-23 08:18:40 -05:00
Milton Gonzalez 820e335d9a Fix session expiration handling 2026-04-23 01:10:15 -05:00
Milton Gonzalez da4b06094e Harden runtime cleanup in production deploy 2026-04-08 23:46:34 -05:00
Milton Gonzalez 331dd40e76 Refine external signing flow and mobile PDF placement 2026-04-08 23:45:47 -05:00
Milton Gonzalez f1bfa5aafc Fix hardened production deploy script 2026-04-08 23:07:33 -05:00
Milton Gonzalez 1e965b6e71 Agregar firmas externas seguras y mejorar entregabilidad de correos 2026-04-08 22:58:24 -05:00
Milton Gonzalez 46ec312706 Actualizar imagen e iconos de marca 2026-04-07 23:49:50 -05:00
Milton Gonzalez 946c19a397 Align web branding with LinkedSign app icon 2026-04-07 22:38:13 -05:00
Milton Gonzalez 89b09cd8ac Harden Android biometric session flow and release process 2026-04-07 21:29:37 -05:00
Milton Gonzalez 15e8d55fca Harden production layout and deploy workflow 2026-04-07 20:28:26 -05:00
Milton Gonzalez 0292b2e85a Harden web session handling and production config 2026-04-07 19:48:48 -05:00
Milton Gonzalez 890f3fff2b Simplify web topbar layout 2026-04-07 19:30:50 -05:00
Milton Gonzalez eec4977bb2 Refine web mobile download prompt and document pagination 2026-04-07 16:57:17 -05:00
Milton Gonzalez 007b5b4b9e Refine Android app into focused mobile signer flow 2026-04-07 16:43:44 -05:00
Milton Gonzalez 605ea94041 implementar visualizacion de logs y seteo de usuario administrador 2026-04-07 16:11:49 -05:00
Milton Gonzalez 9a8e70c943 Implement secure account lock and certificate-based unlock flow 2026-04-07 15:45:08 -05:00
Milton Gonzalez 46a11728b8 se agrega los requerimientos de la LODPD con firma de documentos 2026-04-07 15:14:44 -05:00
107 changed files with 7291 additions and 791 deletions
+1
View File
@@ -67,6 +67,7 @@ android_react/ios/
# --- Editor / OS --- # --- Editor / OS ---
.DS_Store .DS_Store
Thumbs.db Thumbs.db
.token_gitea
.idea/ .idea/
.codex .codex
.tmp-venv/ .tmp-venv/
+107
View File
@@ -170,3 +170,110 @@ Always suggest when a skill can be applied.
## Goal ## Goal
Build a secure, scalable, production-ready digital signing platform suitable for multi-user environments and legal compliance. Build a secure, scalable, production-ready digital signing platform suitable for multi-user environments and legal compliance.
---
## Guía rápida: nuevo tipo de estampado
Cuando se cree o ajuste un nuevo estampado visual de firma, revisar siempre estas capas:
### 1. Vista previa en configuración
- Archivo: `frontend/src/components/ui/SignatureStylePicker.jsx`
- Aquí se agrega el nuevo estilo al arreglo `STYLES`
- Cambiar:
- `id`
- `label`
- `desc`
- `preview(...)`
### 2. Vista previa al colocar la firma sobre el PDF
- Archivo: `frontend/src/components/ui/PdfSignaturePlacer.jsx`
- Aquí se dibuja el estampado que el usuario ve antes de firmar
- Cambiar:
- rama `if (signatureStyle === 'nuevo-estilo')`
- ancho dinámico si aplica en `getSignatureWidth(...)`
- partición de nombre o textos fijos si aplica
### 3. Estilos CSS de las vistas previas
- Archivo: `frontend/src/index.css`
- Aquí viven:
- `.sig-preview-*`
- `.sp-*`
- Cambiar:
- borde
- fondo
- tipografía
- separadores
- layout del QR y textos
### 4. PDF final real que se incrusta al firmar
- Archivo: `backend/src/modules/signing/signing.service.js`
- Este es el archivo clave del sello visible real dentro del PDF
- Cambiar:
- función nueva tipo `drawStyleNuevo(...)`
- selección del estilo en `signPdf(...)`
- helper de líneas/nombre si aplica
- QR, bordes, divisor, tipografía y textos fijos
### 5. Flujo backend que llama al generador PDF
- Firma normal web:
- `backend/src/modules/signatures/signatures.service.js`
- Firma por invitación:
- `backend/src/modules/invitations/invitations.service.js`
- PDF legal firmado:
- `backend/src/modules/legal/legal.service.js`
Si el estampado no cambia en el PDF final, verificar primero que el flujo usado realmente pase por `backend/src/modules/signing/signing.service.js`.
### 6. Orden correcto de trabajo
1. Ajustar preview en `SignatureStylePicker.jsx`
2. Ajustar preview de estampado en `PdfSignaturePlacer.jsx`
3. Ajustar CSS en `frontend/src/index.css`
4. Ajustar sello PDF real en `backend/src/modules/signing/signing.service.js`
5. Validar:
- `cd frontend && npm run build`
- `cd backend && node -e "require('./src/app')"`
6. Si va a producción:
- `commit`
- `push`
- deploy web
### 7. Regla práctica importante
Si el cambio se ve en la web pero no en el PDF firmado:
- casi seguro falta tocar `backend/src/modules/signing/signing.service.js`
- o el backend runtime de producción quedó desalineado respecto a `source`
### 8. Verificación obligatoria en producción para cambios backend
Cuando el cambio afecta el PDF final o cualquier lógica backend:
1. hacer deploy
2. reiniciar `linkedsign-backend`
3. confirmar que el `PID` cambió:
- `ps -ef | grep 'node .*src/server.js' | grep -v grep`
4. revisar que el proceso activo escucha en `:4000`
5. recién después hacer la prueba funcional
Caso ya confirmado en producción:
- el archivo en disco puede tener el fix correcto
- pero si `linkedsign-backend` no reinicia de verdad, el proceso sigue firmando con código viejo en memoria
### 9. Método recomendado para depurar un estampado nuevo
Si un sello no refleja cambios y hay duda de ruta:
1. agregar una marca visual temporal en `backend/src/modules/signing/signing.service.js`
2. desplegar
3. reiniciar backend y confirmar cambio de `PID`
4. firmar un PDF nuevo
5. comprobar si la marca aparece en el PDF final
6. quitar la marca temporal antes de cerrar el trabajo
+198 -1
View File
@@ -2,10 +2,207 @@
## Versión actual ## Versión actual
- Versión base: `1.0.1` - Versión base: `1.2.35`
## Historial ## Historial
### 1.2.35 - 2026-05-22
- `FirmaEC` retira las marcas temporales de control del PDF final y mantiene documentado el flujo correcto para depurar nuevos estampados y verificar reinicio real del backend en producción.
### 1.2.34 - 2026-05-22
- Backend agrega el marcador de log `FIRMAEC_PDF_RENDER` para rastrear en producción si `drawStyleFirmaEC` realmente corre durante la firma PDF.
### 1.2.33 - 2026-05-22
- El estampado web de `FirmaEC` ya no muestra recuadro cuando está en `Sin borde` y parte el nombre en `2 + 2` desde la vista de colocación.
### 1.2.32 - 2026-05-22
- `FirmaEC` agrega la marca `0` al inicio de ambas líneas superiores del sello PDF para validar la ruta real de impresión.
### 1.2.31 - 2026-05-22
- La marca de control `0` de `FirmaEC` pasa a la línea `Firmado electronicamente por:` del sello PDF.
### 1.2.30 - 2026-05-22
- `FirmaEC` agrega la marca de control `0` al inicio del texto superior del sello PDF y devuelve el tamaño del nombre a `8.5`.
### 1.2.29 - 2026-05-22
- `FirmaEC` aumenta el tamaño del nombre visible en el PDF de `8.5` a `12.5`.
### 1.2.28 - 2026-05-22
- `FirmaEC` agrega la marca temporal `prueba` debajo del nombre en el sello PDF para verificar que el backend activo de firma sea el correcto.
### 1.2.27 - 2026-05-22
- `FirmaEC` parte el nombre después de la segunda palabra cuando el certificado trae 4 o más palabras, para que el salto de línea sí se vea en el PDF final.
### 1.2.26 - 2026-05-22
- El sidebar web muestra la versión activa junto a `LinkedSign`.
- `FirmaEC` divide nombres de 4 palabras en `2 + 2` dentro del sello visible del PDF.
### 1.2.25 - 2026-05-22
- La firma normal usa las preferencias visuales guardadas del usuario al momento de firmar, evitando que un estado viejo del frontend vuelva a enviar `FirmaEC` con borde.
### 1.2.24 - 2026-05-22
- `FirmaEC` sin borde ahora usa un widget criptográfico invisible en el PDF final, evitando el rectángulo residual alrededor del sello.
### 1.2.23 - 2026-05-22
- El PDF firmado oculta el borde visible del widget criptográfico de firma, para que `FirmaEC` respete de verdad la opción `Sin borde`.
### 1.2.22 - 2026-05-22
- `FirmaEC` deja de mostrar líneas de borde y separadores cuando la preferencia está en `Sin borde`, tanto al colocar la firma como en el PDF final.
### 1.2.21 - 2026-05-22
- `FirmaEC` respeta la opción `Sin borde` tanto en la vista previa web como en la firma visible del PDF.
### 1.2.20 - 2026-05-21
- Backend deja de rechazar estilos visuales desconocidos al guardar preferencias y los normaliza hacia `FirmaEC` o `QR` según corresponda.
### 1.2.19 - 2026-05-21
- Backend normaliza estilos de firma antes de validar, aceptando variantes de texto y evitando fallos intermitentes al guardar `FirmaEC`.
### 1.2.18 - 2026-05-21
- Backend vuelve tolerante el guardado de preferencias de firma para aceptar clientes parciales o legados y evitar errores al guardar `FirmaEC`.
### 1.2.17 - 2026-05-21
- Web y backend agregan el estilo de firma `FirmaEC`, con vista previa configurable y sello visible con QR de validación.
### 1.2.16 - 2026-04-27
- Web aplica separación explícita entre `Certificado digital`, `Mostrar detalles` y `Elegir posición de firma` en `Firmar ahora`.
### 1.2.15 - 2026-04-27
- Web aumenta de forma visible la separación entre `Certificado digital`, `Mostrar detalles` y `Elegir posición de firma` en `Firmar ahora`.
### 1.2.14 - 2026-04-27
- Web mejora la separación vertical en `Firmar ahora` entre el certificado, el botón `Mostrar detalles` y el botón de elegir posición de firma.
### 1.2.13 - 2026-04-27
- Web y Android ocultan por defecto los detalles avanzados de `Firmar ahora` y los muestran solo bajo demanda con botón de ojo.
### 1.2.12 - 2026-04-23
- Web reordena el menú principal para mostrar `Firmar ahora` en segunda posición y `Mis documentos` justo debajo.
### 1.2.11 - 2026-04-23
- Web fija `Ecuador` como ubicación primaria por defecto en el flujo `Firmar ahora`.
### 1.2.10 - 2026-04-23
- Web muestra en el combo de `Firmar ahora` el PDF cargado con el botón `+`, marcado como documento local hasta completar la firma.
### 1.2.9 - 2026-04-23
- Web evita el refresco manual después de subir PDFs y actualiza la lista de documentos en caliente.
- El sello visible de firma usa el huso horario y locale detectados en el navegador del usuario.
- `Firmar ahora` agrega selector con botón para elegir documento y botón `+` para cargar un PDF desde el mismo flujo.
### 1.2.8 - 2026-04-23
- Android simplifica la pantalla biométrica: se ocultan `Face ID`, `PIN` y `SMS OTP`.
- Se elimina el texto “Coloque su dedo en el sensor”.
### 1.2.7 - 2026-04-23
- Android relaja la detección de biometría para aceptar equipos con soporte biométrico débil o fuerte, evitando que deje de ofrecer la activación por huella.
### 1.2.6 - 2026-04-23
- Android deja de desactivar la huella cuando se cierra o pospone el diálogo de oferta biométrica.
- La oferta de activar huella reaparece mientras aún no existan credenciales biométricas guardadas.
### 1.2.5 - 2026-04-23
- Android vuelve a bloquearse al pasar a segundo plano cuando la biometría está activa, para que al regresar sí pida huella.
- Se publica APK nuevo con versión visible `1.2.5`.
### 1.2.4 - 2026-04-23
- Android corrige el cierre de sesión para volver a ofrecer la pantalla biométrica cuando existen credenciales biométricas guardadas.
- Se publica APK nuevo con versión visible `1.2.4`.
### 1.2.3 - 2026-04-23
- La web fuerza uso de `/api` y `/api/downloads/android-apk` cuando corre en `sign.linkedcorp.net`, evitando compilar enlaces de producción con IPs de desarrollo.
- Se corrigen referencias heredadas del servidor `192.168.222.72` hacia `192.168.222.40` en configuraciones y documentación de desarrollo.
### 1.2.2 - 2026-04-23
- Android vuelve a ofrecer activar huella después del login siempre que el acceso biométrico no esté activo.
- Se conserva la persistencia del acceso biométrico ya implementada en la versión anterior.
### 1.2.1 - 2026-04-23
- Android muestra `About` automáticamente en el primer login de cada usuario y luego vuelve por defecto a `Documentos`.
- `About` agrega acceso a los documentos legales firmados del registro para abrirlos o descargarlos directamente en el teléfono.
- La preferencia de haber visto `About` queda persistente entre reinicios y actualizaciones de la app.
### 1.2.0 - 2026-04-23
- Se separa el acceso biométrico Android de la sesión normal usando un token biométrico persistente.
- La huella se mantiene aunque expire la sesión por inactividad.
- La huella solo se invalida al desactivarla o al cambiar contraseña.
- Se evita que “Continuar sin huella” desactive el acceso biométrico por error.
### 1.1.3 - 2026-04-23
- Se cambia en Android el botón de ojo por descarga real del PDF.
- El botón con ícono PDF ahora abre el documento localmente dentro de la app.
### 1.1.2 - 2026-04-23
- Se separa en Android la vista de documentos firmados y originales.
- Se deja una sola acción para abrir documentos en Android y se elimina la acción duplicada.
- Se renueva la sesión por actividad en web y Android para que expire tras inactividad.
- Se refresca el token Android antes de abrir documentos cuando está cerca de vencer.
### 1.1.1 - 2026-04-23
- Se oculta la barra de navegación del sistema Android en modo inmersivo.
- Se ajusta el alto del menú inferior Android para evitar recortes en los íconos.
### 1.1.0 - 2026-04-23
- Se agrega pantalla About en Android con versión y texto de uso.
- Se actualiza el encabezado Android a la marca solicitada.
- Se reduce la altura del menú inferior Android sin cambiar el tamaño de los íconos.
### 1.0.4 - 2026-04-23
- Se muestra la versión de LinkedSign en el inicio de sesión web y Android.
### 1.0.3 - 2026-04-23
- Se ajusta la duración completa de sesión web y Android a 15 minutos.
- Se fuerza cierre local y retorno al inicio de sesión cuando la sesión expira o no puede renovarse.
- Se traducen mensajes de expiración de sesión al español.
### 1.0.2 - 2026-04-08
- Se endurece el flujo biométrico Android para usar sesión por token en lugar de guardar la contraseña del usuario.
- Se actualizan las guías de despliegue para dejar explícito que en producción solo se publica el `APK release`.
- Se refuerza la regla operativa de mantener producción en estado `hardened`.
### 1.0.1 - 2026-04-07 ### 1.0.1 - 2026-04-07
- Se unifica la versión inicial de `frontend`, `backend` y `android`. - Se unifica la versión inicial de `frontend`, `backend` y `android`.
+12 -2
View File
@@ -2,7 +2,7 @@
## Versión actual ## Versión actual
- Base actual: `1.0.1` - Base actual: `1.2.34`
- Fuente de verdad: [VERSION](/home/mgonzalez/desarrollo/react/linkedsign/VERSION) - Fuente de verdad: [VERSION](/home/mgonzalez/desarrollo/react/linkedsign/VERSION)
- Historial de cambios: [CHANGELOG.md](/home/mgonzalez/desarrollo/react/linkedsign/CHANGELOG.md) - Historial de cambios: [CHANGELOG.md](/home/mgonzalez/desarrollo/react/linkedsign/CHANGELOG.md)
@@ -12,6 +12,7 @@
2. Si corresponde, sube versión: 2. Si corresponde, sube versión:
- `./scripts/set-version.sh X.Y.Z` - `./scripts/set-version.sh X.Y.Z`
- actualiza `CHANGELOG.md` - actualiza `CHANGELOG.md`
- confirma que `android/app/build.gradle` refleje el nuevo `versionName` y `versionCode`
3. Prueba la app: 3. Prueba la app:
- `source /etc/profile.d/android-sdk.sh` - `source /etc/profile.d/android-sdk.sh`
- `cd android` - `cd android`
@@ -26,7 +27,8 @@
7. Sube a Gitea: 7. Sube a Gitea:
- `git push origin main` - `git push origin main`
8. Sube el APK a producción: 8. Sube el APK a producción:
- `scp -P 2203 android/apk/LinkedSign-release.apk deploy@192.168.222.3:/opt/apps/linkedsign/apk/` - `ssh deploy@192.168.222.3 -p 2203 'mkdir -p /opt/apps/linkedsign/android/apk'`
- `scp -P 2203 android/apk/LinkedSign-release.apk deploy@192.168.222.3:/opt/apps/linkedsign/android/apk/`
9. Limpia archivos `.md` en producción: 9. Limpia archivos `.md` en producción:
- `find /opt/apps/linkedsign -type f -iname '*.md' -delete` - `find /opt/apps/linkedsign -type f -iname '*.md' -delete`
10. Verifica descarga pública: 10. Verifica descarga pública:
@@ -38,3 +40,11 @@
- No pierdas la `keystore` ni las contraseñas. - No pierdas la `keystore` ni las contraseñas.
- Si cambias algo visual de la app, no hace falta tocar la web. - Si cambias algo visual de la app, no hace falta tocar la web.
- Si cambias el contrato del API, prueba login, documentos y firma antes de subir release. - Si cambias el contrato del API, prueba login, documentos y firma antes de subir release.
- En producción no debe quedar `android/` fuente; solo `/opt/apps/linkedsign/android/apk/LinkedSign-release.apk`.
- En cada paso a producción Android se publica únicamente el `APK release`; no se sube el código fuente Android.
- El `APK release` que se publique debe corresponder a una versión nueva y anotada en `VERSION` y `CHANGELOG.md`.
- El paso Android no debe tocar ni reemplazar:
- `/opt/apps/linkedsign/backend/.env`
- `/opt/apps/linkedsign/storage/`
- `/opt/apps/linkedsign/logs/`
- configuración runtime hardened ya existente en producción
+28 -18
View File
@@ -2,7 +2,7 @@
## Versión actual ## Versión actual
- Base actual: `1.0.1` - Base actual: `1.2.34`
- Fuente de verdad: [VERSION](/home/mgonzalez/desarrollo/react/linkedsign/VERSION) - Fuente de verdad: [VERSION](/home/mgonzalez/desarrollo/react/linkedsign/VERSION)
- Historial de cambios: [CHANGELOG.md](/home/mgonzalez/desarrollo/react/linkedsign/CHANGELOG.md) - Historial de cambios: [CHANGELOG.md](/home/mgonzalez/desarrollo/react/linkedsign/CHANGELOG.md)
@@ -26,29 +26,39 @@
- `cd /opt/apps/linkedsign/source` - `cd /opt/apps/linkedsign/source`
- `git fetch origin main` - `git fetch origin main`
- `git reset --hard origin/main` - `git reset --hard origin/main`
8. Recompila frontend: - `rm -rf /opt/apps/linkedsign/source/android /opt/apps/linkedsign/source/android_react /opt/apps/linkedsign/source/android_firma_electronica.html`
- `cd /opt/apps/linkedsign/source/frontend` 8. En producción usa el script seguro:
- `npm ci` - `cd /opt/apps/linkedsign/source`
- `npm run build` - `bash scripts/deploy_prod_web.sh`
9. Publica frontend: 9. Reinicia servicios:
- `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: - Si hubo cambios en `backend/`, este reinicio es obligatorio
- Verifica que el backend sí reinició de verdad:
- `ps -ef | grep 'node .*src/server.js' | grep -v grep`
- confirma que cambió el `PID`
- `sudo systemctl status linkedsign-backend --no-pager -n 20`
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/`.
- Si en el despliegue hay cambios Android, recuerda que a producción solo pasa el `APK release` versionado; no el proyecto Android completo.
- En cambios de backend no basta con copiar archivos o correr el script de deploy:
- el proceso `node src/server.js` debe reiniciarse sí o sí
- si el `PID` no cambia, el backend puede seguir firmando con código viejo en memoria aunque el archivo en disco ya esté actualizado
- 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
+1 -1
View File
@@ -2,7 +2,7 @@
## Versión actual ## Versión actual
- Base actual: `1.0.1` - Base actual: `1.2.34`
- Fuente de verdad: [VERSION](/home/mgonzalez/desarrollo/react/linkedsign/VERSION) - Fuente de verdad: [VERSION](/home/mgonzalez/desarrollo/react/linkedsign/VERSION)
- Historial de cambios: [CHANGELOG.md](/home/mgonzalez/desarrollo/react/linkedsign/CHANGELOG.md) - Historial de cambios: [CHANGELOG.md](/home/mgonzalez/desarrollo/react/linkedsign/CHANGELOG.md)
+1 -1
View File
@@ -1 +1 @@
1.0.1 1.2.35
+1 -1
View File
@@ -58,7 +58,7 @@ android/
Por defecto la app apunta a: Por defecto la app apunta a:
```text ```text
http://192.168.222.72:4000 http://192.168.222.40:4000
``` ```
Ese valor está en: Ese valor está en:
+2 -2
View File
@@ -18,8 +18,8 @@ android {
applicationId "net.linkedcorp.linkedsign" applicationId "net.linkedcorp.linkedsign"
minSdk 26 minSdk 26
targetSdk 35 targetSdk 35
versionCode 10001 versionCode 10235
versionName "1.0.1" versionName "1.2.35"
buildConfigField "String", "API_BASE_URL", "\"${apiBaseUrl}\"" buildConfigField "String", "API_BASE_URL", "\"${apiBaseUrl}\""
} }
@@ -5,6 +5,8 @@ import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import net.linkedcorp.linkedsign.ui.LinkedSignApp import net.linkedcorp.linkedsign.ui.LinkedSignApp
import net.linkedcorp.linkedsign.ui.MainViewModel import net.linkedcorp.linkedsign.ui.MainViewModel
@@ -14,6 +16,7 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
enableEdgeToEdge() enableEdgeToEdge()
hideSystemNavigation()
setContent { setContent {
LinkedSignTheme { LinkedSignTheme {
@@ -28,4 +31,21 @@ class MainActivity : ComponentActivity() {
} }
} }
} }
override fun onResume() {
super.onResume()
hideSystemNavigation()
}
override fun onWindowFocusChanged(hasFocus: Boolean) {
super.onWindowFocusChanged(hasFocus)
if (hasFocus) hideSystemNavigation()
}
private fun hideSystemNavigation() {
WindowInsetsControllerCompat(window, window.decorView).apply {
systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
hide(WindowInsetsCompat.Type.navigationBars())
}
}
} }
@@ -16,18 +16,34 @@ interface LinkedSignApiService {
@POST("auth/login") @POST("auth/login")
suspend fun login(@Body body: LoginRequest): ApiEnvelope<LoginData> suspend fun login(@Body body: LoginRequest): ApiEnvelope<LoginData>
@Multipart
@POST("auth/register") @POST("auth/register")
suspend fun register(@Body body: RegisterRequest): ApiEnvelope<RegisterData> suspend fun register(
@Part("email") email: RequestBody,
@Part("password") password: RequestBody,
@Part("certPassword") certPassword: RequestBody,
@Part("consentAccepted") consentAccepted: RequestBody,
@Part certificateFile: MultipartBody.Part
): ApiEnvelope<RegisterData>
@POST("auth/refresh") @POST("auth/refresh")
suspend fun refresh(@Body body: RefreshRequest): ApiEnvelope<RefreshData> suspend fun refresh(@Body body: RefreshRequest): ApiEnvelope<RefreshData>
@POST("auth/biometric-login")
suspend fun biometricLogin(@Body body: BiometricTokenRequest): ApiEnvelope<BiometricLoginData>
@POST("auth/logout") @POST("auth/logout")
suspend fun logout(@Body body: RefreshRequest): ApiEnvelope<SimpleMessageData> suspend fun logout(@Body body: RefreshRequest): ApiEnvelope<SimpleMessageData>
@GET("auth/me") @GET("auth/me")
suspend fun me(@Header("Authorization") authorization: String): ApiEnvelope<MeData> suspend fun me(@Header("Authorization") authorization: String): ApiEnvelope<MeData>
@POST("auth/biometric-token")
suspend fun issueBiometricToken(@Header("Authorization") authorization: String): ApiEnvelope<BiometricTokenData>
@DELETE("auth/biometric-token")
suspend fun revokeBiometricToken(@Header("Authorization") authorization: String): ApiEnvelope<SimpleMessageData>
@PATCH("auth/change-password") @PATCH("auth/change-password")
suspend fun changePassword( suspend fun changePassword(
@Header("Authorization") authorization: String, @Header("Authorization") authorization: String,
@@ -72,6 +88,11 @@ interface LinkedSignApiService {
@Header("Authorization") authorization: String @Header("Authorization") authorization: String
): ApiEnvelope<List<SignedDocumentItem>> ): ApiEnvelope<List<SignedDocumentItem>>
@GET("legal")
suspend fun listLegalDocuments(
@Header("Authorization") authorization: String
): ApiEnvelope<List<LegalDocumentItem>>
@DELETE("signatures/{id}") @DELETE("signatures/{id}")
suspend fun deleteSignedDocument( suspend fun deleteSignedDocument(
@Header("Authorization") authorization: String, @Header("Authorization") authorization: String,
@@ -21,6 +21,8 @@ import java.io.FileInputStream
import java.io.IOException import java.io.IOException
import java.util.Locale import java.util.Locale
private const val TOKEN_REFRESH_MARGIN_MS = 6 * 60 * 1000L
class LinkedSignRepository(private val context: Context) { class LinkedSignRepository(private val context: Context) {
private val sessionStore = SessionStore(context) private val sessionStore = SessionStore(context)
private val localCache = LocalCache(context) private val localCache = LocalCache(context)
@@ -79,17 +81,25 @@ class LinkedSignRepository(private val context: Context) {
} }
} }
suspend fun register(email: String, password: String): RegisterData { suspend fun register(email: String, password: String, certPassword: String, certificateUri: Uri, certificateName: String): RegisterData {
return execute { return execute {
unwrap(service.register(RegisterRequest(email.trim(), password))) val filePart = withContext(Dispatchers.IO) {
createFilePart(certificateUri, certificateName, "application/octet-stream", "certificateFile")
}
val emailBody = email.trim().toRequestBody("text/plain".toMediaType())
val passwordBody = password.toRequestBody("text/plain".toMediaType())
val certPasswordBody = certPassword.toRequestBody("text/plain".toMediaType())
val consentBody = "true".toRequestBody("text/plain".toMediaType())
unwrap(service.register(emailBody, passwordBody, certPasswordBody, consentBody, filePart))
} }
} }
suspend fun logout() { suspend fun logout() {
execute { execute {
val session = sessionStore.load() val session = sessionStore.load()
val biometric = sessionStore.loadBiometricState()
try { try {
if (!session.refreshToken.isNullOrBlank()) { if (!session.refreshToken.isNullOrBlank() && !biometric.enabled) {
unwrap(service.logout(RefreshRequest(session.refreshToken))) unwrap(service.logout(RefreshRequest(session.refreshToken)))
} }
} finally { } finally {
@@ -99,30 +109,63 @@ class LinkedSignRepository(private val context: Context) {
} }
suspend fun biometricLogin(): SessionSnapshot { suspend fun biometricLogin(): SessionSnapshot {
val credentials = sessionStore.loadBiometricCredentials() val biometric = sessionStore.loadBiometricToken()
?: throw AppException("No hay credenciales biométricas guardadas.") ?: throw AppException("No hay sesión biométrica guardada.")
return login(credentials.first, credentials.second) return try {
execute {
val response = unwrap(service.biometricLogin(BiometricTokenRequest(biometric.second)))
val session = SessionSnapshot(
accessToken = response.accessToken,
refreshToken = response.refreshToken,
user = response.user
)
sessionStore.save(session)
localCache.saveProfile(response.user)
sessionStore.updateBiometricSession(response.user.email, response.biometricToken)
session
}
} catch (error: AppException) {
if (error.code == "BIOMETRIC_EXPIRED" || error.code == "BIOMETRIC_INVALIDATED") {
sessionStore.declineBiometricAccess()
}
throw error
}
} }
fun biometricState(): BiometricAccessState = sessionStore.loadBiometricState() fun biometricState(): BiometricAccessState = sessionStore.loadBiometricState()
fun enableBiometricAccess(email: String, password: String) { suspend fun enableBiometricAccess() {
sessionStore.saveBiometricAccess(email.trim(), password) val session = sessionStore.load()
val user = session.user ?: throw AppException("Primero debes iniciar sesión.")
val accessToken = session.accessToken ?: throw AppException("No hay sesión para activar biometría.")
val response = execute {
unwrap(service.issueBiometricToken("Bearer $accessToken"))
}
sessionStore.saveBiometricAccess(user.email, response.biometricToken)
} }
fun declineBiometricAccess() { suspend fun declineBiometricAccess() {
val session = sessionStore.load()
val accessToken = session.accessToken
if (!accessToken.isNullOrBlank()) {
runCatching {
execute {
unwrap(service.revokeBiometricToken("Bearer $accessToken"))
}
}
}
sessionStore.declineBiometricAccess() sessionStore.declineBiometricAccess()
} }
fun syncBiometricCredentials(email: String, password: String) {
sessionStore.updateBiometricCredentials(email.trim(), password)
}
suspend fun requestPasswordChange(currentPassword: String, newPassword: String): SimpleMessageData = suspend fun requestPasswordChange(currentPassword: String, newPassword: String): SimpleMessageData =
execute { withAuth { auth -> execute { withAuth { auth ->
unwrap(service.changePassword(auth, ChangePasswordRequest(currentPassword, newPassword))) unwrap(service.changePassword(auth, ChangePasswordRequest(currentPassword, newPassword)))
} } } }
suspend fun listLegalDocuments(): List<LegalDocumentItem> = execute { withAuth { auth ->
unwrap(service.listLegalDocuments(auth))
} }
suspend fun syncAll(): CacheSnapshot = execute { withAuth { auth -> suspend fun syncAll(): CacheSnapshot = execute { withAuth { auth ->
val documents = unwrap(service.listDocuments(auth)).rows val documents = unwrap(service.listDocuments(auth)).rows
val certificates = unwrap(service.listCertificates(auth)) val certificates = unwrap(service.listCertificates(auth))
@@ -176,7 +219,22 @@ class LinkedSignRepository(private val context: Context) {
result result
} } } }
fun getAccessToken(): String? = sessionStore.load().accessToken suspend fun getFreshAccessToken(): String? {
val session = sessionStore.load()
if (session.accessToken.isNullOrBlank()) return null
if ((session.expiresAt ?: Long.MAX_VALUE) > System.currentTimeMillis() + TOKEN_REFRESH_MARGIN_MS) {
return session.accessToken
}
return refreshSession(session).accessToken
}
fun hasSeenAboutForUser(email: String): Boolean = sessionStore.hasSeenAboutForUser(email)
fun markAboutSeenForUser(email: String) {
sessionStore.markAboutSeenForUser(email)
}
private suspend fun <T> withAuth(block: suspend (String) -> T): T { private suspend fun <T> withAuth(block: suspend (String) -> T): T {
val session = sessionStore.load() val session = sessionStore.load()
@@ -187,15 +245,29 @@ class LinkedSignRepository(private val context: Context) {
} catch (error: HttpException) { } catch (error: HttpException) {
if (error.code() != 401) throw mapHttpException(error) if (error.code() != 401) throw mapHttpException(error)
val refreshToken = session.refreshToken ?: throw AppException("Tu sesión expiró. Ingresa nuevamente.") val updated = refreshSession(session)
val refresh = unwrap(service.refresh(RefreshRequest(refreshToken))) block("Bearer ${updated.accessToken}")
}
}
private suspend fun refreshSession(session: SessionSnapshot): SessionSnapshot {
val refreshToken = session.refreshToken ?: throwSessionExpired()
val refresh = try {
unwrap(service.refresh(RefreshRequest(refreshToken)))
} catch (refreshError: Exception) {
sessionStore.clearSession()
throwSessionExpired()
}
val updated = session.copy( val updated = session.copy(
accessToken = refresh.accessToken, accessToken = refresh.accessToken,
refreshToken = refresh.refreshToken refreshToken = refresh.refreshToken
) )
sessionStore.save(updated) sessionStore.save(updated)
block("Bearer ${refresh.accessToken}") return updated
} }
private fun throwSessionExpired(): Nothing {
throw AppException("Tu sesión expiró. Vuelve a iniciar sesión.", "SESSION_EXPIRED")
} }
private suspend fun <T> execute(block: suspend () -> T): T { private suspend fun <T> execute(block: suspend () -> T): T {
@@ -43,9 +43,19 @@ data class LoginData(
val user: SessionUser val user: SessionUser
) )
data class RegisterRequest( data class BiometricTokenRequest(
val email: String, val biometricToken: String
val password: String )
data class BiometricTokenData(
val biometricToken: String
)
data class BiometricLoginData(
val accessToken: String,
val refreshToken: String,
val biometricToken: String,
val user: SessionUser
) )
data class RegisterData( data class RegisterData(
@@ -128,6 +138,21 @@ data class SignedDocumentItem(
val signedAt: String? val signedAt: String?
) )
data class LegalDocumentItem(
val id: String,
val slug: String?,
val title: String?,
val version: String?,
@Json(name = "effectiveDate")
val effectiveDate: String?,
@Json(name = "createdAt")
val createdAt: String?,
@Json(name = "signedFilename")
val signedFilename: String?,
@Json(name = "hashSha256")
val hashSha256: String?
)
data class SignRequest( data class SignRequest(
val documentId: Int, val documentId: Int,
val certificateId: Int, val certificateId: Int,
@@ -161,7 +186,8 @@ data class SignData(
data class SessionSnapshot( data class SessionSnapshot(
val accessToken: String? = null, val accessToken: String? = null,
val refreshToken: String? = null, val refreshToken: String? = null,
val user: SessionUser? = null val user: SessionUser? = null,
val expiresAt: Long? = null
) )
data class BiometricAccessState( data class BiometricAccessState(
@@ -3,8 +3,10 @@ package net.linkedcorp.linkedsign.data
import android.content.Context import android.content.Context
import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey import androidx.security.crypto.MasterKey
import android.util.Base64
import com.squareup.moshi.Moshi import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import org.json.JSONObject
class SessionStore(context: Context) { class SessionStore(context: Context) {
private val moshi = Moshi.Builder().add(KotlinJsonAdapterFactory()).build() private val moshi = Moshi.Builder().add(KotlinJsonAdapterFactory()).build()
@@ -26,15 +28,22 @@ class SessionStore(context: Context) {
val accessToken = preferences.getString(KEY_ACCESS, null) val accessToken = preferences.getString(KEY_ACCESS, null)
val refreshToken = preferences.getString(KEY_REFRESH, null) val refreshToken = preferences.getString(KEY_REFRESH, null)
val userJson = preferences.getString(KEY_USER, null) val userJson = preferences.getString(KEY_USER, null)
val expiresAt = preferences.getLong(KEY_EXPIRES_AT, 0L).takeIf { it > 0L }
val user = userJson?.let { userAdapter.fromJson(it) } val user = userJson?.let { userAdapter.fromJson(it) }
return SessionSnapshot(accessToken, refreshToken, user) return SessionSnapshot(accessToken, refreshToken, user, expiresAt)
} }
fun save(session: SessionSnapshot) { fun save(session: SessionSnapshot) {
val expiresAt = session.expiresAt
?: session.accessToken?.let { readAccessTokenExpiry(it) }
?: (System.currentTimeMillis() + DEFAULT_SESSION_MS)
preferences.edit() preferences.edit()
.putString(KEY_ACCESS, session.accessToken) .putString(KEY_ACCESS, session.accessToken)
.putString(KEY_REFRESH, session.refreshToken) .putString(KEY_REFRESH, session.refreshToken)
.putString(KEY_USER, session.user?.let { userAdapter.toJson(it) }) .putString(KEY_USER, session.user?.let { userAdapter.toJson(it) })
.putLong(KEY_EXPIRES_AT, expiresAt)
.apply() .apply()
} }
@@ -47,29 +56,41 @@ class SessionStore(context: Context) {
.remove(KEY_ACCESS) .remove(KEY_ACCESS)
.remove(KEY_REFRESH) .remove(KEY_REFRESH)
.remove(KEY_USER) .remove(KEY_USER)
.remove(KEY_EXPIRES_AT)
.apply() .apply()
} }
private fun readAccessTokenExpiry(accessToken: String): Long? {
return try {
val payload = accessToken.split(".").getOrNull(1) ?: return null
val decoded = String(Base64.decode(payload, Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING))
val exp = JSONObject(decoded).optLong("exp", 0L)
if (exp > 0L) exp * 1000L else null
} catch (_: Exception) {
null
}
}
fun loadBiometricState(): BiometricAccessState { fun loadBiometricState(): BiometricAccessState {
val enabled = preferences.getBoolean(KEY_BIO_ENABLED, false) val enabled = preferences.getBoolean(KEY_BIO_ENABLED, false)
val configured = preferences.getBoolean(KEY_BIO_CONFIGURED, false) val configured = preferences.getBoolean(KEY_BIO_CONFIGURED, false)
val email = preferences.getString(KEY_BIO_EMAIL, null) val email = preferences.getString(KEY_BIO_EMAIL, null)
val password = preferences.getString(KEY_BIO_PASSWORD, null) val biometricToken = preferences.getString(KEY_BIO_TOKEN, null)
return BiometricAccessState( return BiometricAccessState(
enabled = enabled, enabled = enabled,
configured = configured, configured = configured,
email = email, email = email,
hasCredentials = !email.isNullOrBlank() && !password.isNullOrBlank() hasCredentials = !email.isNullOrBlank() && !biometricToken.isNullOrBlank()
) )
} }
fun saveBiometricAccess(email: String, password: String) { fun saveBiometricAccess(email: String, biometricToken: String) {
preferences.edit() preferences.edit()
.putBoolean(KEY_BIO_ENABLED, true) .putBoolean(KEY_BIO_ENABLED, true)
.putBoolean(KEY_BIO_CONFIGURED, true) .putBoolean(KEY_BIO_CONFIGURED, true)
.putString(KEY_BIO_EMAIL, email) .putString(KEY_BIO_EMAIL, email)
.putString(KEY_BIO_PASSWORD, password) .putString(KEY_BIO_TOKEN, biometricToken)
.apply() .apply()
} }
@@ -78,37 +99,58 @@ class SessionStore(context: Context) {
.putBoolean(KEY_BIO_ENABLED, false) .putBoolean(KEY_BIO_ENABLED, false)
.putBoolean(KEY_BIO_CONFIGURED, true) .putBoolean(KEY_BIO_CONFIGURED, true)
.remove(KEY_BIO_EMAIL) .remove(KEY_BIO_EMAIL)
.remove(KEY_BIO_PASSWORD) .remove(KEY_BIO_TOKEN)
.apply() .apply()
} }
fun updateBiometricCredentials(email: String, password: String) { fun updateBiometricSession(email: String, biometricToken: String) {
if (!preferences.getBoolean(KEY_BIO_ENABLED, false)) { if (!preferences.getBoolean(KEY_BIO_ENABLED, false)) {
return return
} }
preferences.edit() preferences.edit()
.putString(KEY_BIO_EMAIL, email) .putString(KEY_BIO_EMAIL, email)
.putString(KEY_BIO_PASSWORD, password) .putString(KEY_BIO_TOKEN, biometricToken)
.apply() .apply()
} }
fun loadBiometricCredentials(): Pair<String, String>? { fun loadBiometricToken(): Pair<String, String>? {
val email = preferences.getString(KEY_BIO_EMAIL, null) val email = preferences.getString(KEY_BIO_EMAIL, null)
val password = preferences.getString(KEY_BIO_PASSWORD, null) val biometricToken = preferences.getString(KEY_BIO_TOKEN, null)
if (email.isNullOrBlank() || password.isNullOrBlank()) { if (email.isNullOrBlank() || biometricToken.isNullOrBlank()) {
return null return null
} }
return email to password return email to biometricToken
}
fun hasSeenAboutForUser(email: String): Boolean {
val normalizedEmail = email.trim().lowercase()
if (normalizedEmail.isBlank()) return true
return preferences.getStringSet(KEY_ABOUT_SEEN_USERS, emptySet()).orEmpty().contains(normalizedEmail)
}
fun markAboutSeenForUser(email: String) {
val normalizedEmail = email.trim().lowercase()
if (normalizedEmail.isBlank()) return
val current = preferences.getStringSet(KEY_ABOUT_SEEN_USERS, emptySet()).orEmpty().toMutableSet()
if (current.add(normalizedEmail)) {
preferences.edit()
.putStringSet(KEY_ABOUT_SEEN_USERS, current)
.apply()
}
} }
companion object { companion object {
private const val KEY_ACCESS = "access_token" private const val KEY_ACCESS = "access_token"
private const val KEY_REFRESH = "refresh_token" private const val KEY_REFRESH = "refresh_token"
private const val KEY_USER = "user" private const val KEY_USER = "user"
private const val KEY_EXPIRES_AT = "expires_at"
private const val KEY_BIO_ENABLED = "bio_enabled" private const val KEY_BIO_ENABLED = "bio_enabled"
private const val KEY_BIO_CONFIGURED = "bio_configured" private const val KEY_BIO_CONFIGURED = "bio_configured"
private const val KEY_BIO_EMAIL = "bio_email" private const val KEY_BIO_EMAIL = "bio_email"
private const val KEY_BIO_PASSWORD = "bio_password" private const val KEY_BIO_TOKEN = "bio_token"
private const val KEY_ABOUT_SEEN_USERS = "about_seen_users"
private const val DEFAULT_SESSION_MS = 15 * 60 * 1000L
} }
} }
File diff suppressed because it is too large Load Diff
@@ -36,6 +36,7 @@ data class MainUiState(
class MainViewModel(application: Application) : AndroidViewModel(application) { class MainViewModel(application: Application) : AndroidViewModel(application) {
private val repository = LinkedSignRepository(application.applicationContext) private val repository = LinkedSignRepository(application.applicationContext)
private var lastActivityAt = System.currentTimeMillis()
var uiState by mutableStateOf(MainUiState()) var uiState by mutableStateOf(MainUiState())
private set private set
@@ -52,7 +53,6 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
suspend fun login(email: String, password: String) { suspend fun login(email: String, password: String) {
val session = repository.login(email, password) val session = repository.login(email, password)
repository.syncBiometricCredentials(email, password)
val (_, cache) = repository.loadLocalSnapshot() val (_, cache) = repository.loadLocalSnapshot()
uiState = buildState(session, cache, loading = false) uiState = buildState(session, cache, loading = false)
} }
@@ -63,8 +63,8 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
uiState = buildState(session, cache, loading = false) uiState = buildState(session, cache, loading = false)
} }
suspend fun register(email: String, password: String) { suspend fun register(email: String, password: String, certPassword: String, certificateUri: Uri, certificateName: String) {
repository.register(email, password) repository.register(email, password, certPassword, certificateUri, certificateName)
uiState = uiState.copy(emailForVerificationNotice = email) uiState = uiState.copy(emailForVerificationNotice = email)
} }
@@ -137,24 +137,52 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
repository.requestPasswordChange(currentPassword, newPassword) repository.requestPasswordChange(currentPassword, newPassword)
} }
fun noteUserActivity() {
lastActivityAt = System.currentTimeMillis()
}
suspend fun keepSessionAliveIfActive() {
if (!uiState.authenticated) return
if (System.currentTimeMillis() - lastActivityAt > IDLE_SESSION_MS) return
try {
repository.getFreshAccessToken()
val (session, cache) = repository.loadLocalSnapshot()
uiState = buildState(session, cache, loading = false, syncing = uiState.syncing)
} catch (error: Exception) {
normalize(error)
}
}
suspend fun logout() { suspend fun logout() {
repository.logout() repository.logout()
val (session, cache) = repository.loadLocalSnapshot() val (session, cache) = repository.loadLocalSnapshot()
uiState = buildState(session, cache, loading = false) uiState = buildState(session, cache, loading = false)
} }
fun accessToken(): String? = repository.getAccessToken() suspend fun accessToken(): String? = repository.getFreshAccessToken()
fun enableBiometricAccess(email: String, password: String) { suspend fun enableBiometricAccess() {
repository.enableBiometricAccess(email, password) repository.enableBiometricAccess()
refreshState() refreshState()
} }
fun declineBiometricAccess() { suspend fun declineBiometricAccess() {
repository.declineBiometricAccess() repository.declineBiometricAccess()
refreshState() refreshState()
} }
suspend fun listLegalDocuments(): List<net.linkedcorp.linkedsign.data.LegalDocumentItem> =
repository.listLegalDocuments()
fun consumeAboutLandingForCurrentUser(): Boolean {
val email = repository.loadLocalSnapshot().first.user?.email ?: return false
if (repository.hasSeenAboutForUser(email)) {
return false
}
repository.markAboutSeenForUser(email)
return true
}
private fun buildState( private fun buildState(
session: SessionSnapshot, session: SessionSnapshot,
cache: CacheSnapshot, cache: CacheSnapshot,
@@ -189,6 +217,15 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
uiState = buildState(session, cache, loading = false, syncing = uiState.syncing) uiState = buildState(session, cache, loading = false, syncing = uiState.syncing)
} }
private fun normalize(error: Exception): AppException = private fun normalize(error: Exception): AppException {
if (error is AppException) error else AppException(error.message ?: "Ocurrió un error inesperado.") if (error is AppException && error.code == "SESSION_EXPIRED") {
refreshState()
}
return if (error is AppException) error else AppException(error.message ?: "Ocurrió un error inesperado.")
}
companion object {
private const val IDLE_SESSION_MS = 15 * 60 * 1000L
}
} }
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
LinkedSign adaptive icon background
Radial gradient: indigo center → deep navy edge (mirrors LinkedCorp blue circle)
-->
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<gradient
android:type="radial"
android:gradientRadius="75%"
android:startColor="#5B56F2"
android:centerColor="#4338CA"
android:endColor="#1E1B4B"/>
</shape>
@@ -1,15 +1,49 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
LinkedSign adaptive icon foreground — 108×108dp canvas
Design: fountain pen (pluma estilográfica) centered in safe zone (72dp)
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android" <vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp" android:width="108dp"
android:height="108dp" android:height="108dp"
android:viewportWidth="108" android:viewportWidth="108"
android:viewportHeight="108"> android:viewportHeight="108">
<!-- Pen barrel (cuerpo redondeado) -->
<path <path
android:fillColor="#FFFFFF" android:fillColor="#FFFFFF"
android:pathData="M54,18c-11.2,8.4 -22.9,12.6 -35,12.6v21.8c0,24.5 14.8,34.4 35,40.6c20.2,-6.2 35,-16.1 35,-40.6V30.6C76.9,30.6 65.2,26.4 54,18z" /> android:pathData="M38,14 L70,14 Q84,14 84,28 L84,62 Q84,76 70,62 L38,62 Q24,76 24,62 L24,28 Q24,14 38,14Z"/>
<!-- Barrel inner groove (hendidura decorativa) -->
<path <path
android:fillColor="#4F46E5" android:fillColor="#4338CA"
android:pathData="M40,38h12c7.9,0 14,5.3 14,12.5S59.9,63 52,63h-4v8h-8V38zm8,7v11h3.7c3.7,0 6.1,-2.1 6.1,-5.5c0,-3.5 -2.4,-5.5 -6.1,-5.5H48z" /> android:alpha="0.30"
android:pathData="M50,18 L58,18 Q62,18 62,22 L62,58 Q62,62 58,62 L50,62 Q46,62 46,58 L46,22 Q46,18 50,18Z"/>
<!-- Pen clip (lateral) -->
<path <path
android:fillColor="#0F172A" android:fillColor="#FFFFFF"
android:pathData="M38,76h32v4H38z" /> android:alpha="0.50"
android:pathData="M68,16 L73,16 Q75,16 75,18 L75,58 Q75,60 73,60 L68,60 Q66,60 66,58 L66,18 Q66,16 68,16Z"/>
<!-- Pen nib (plumín triangular) -->
<path
android:fillColor="#FFFFFF"
android:pathData="M38,62 L54,90 L70,62Z"/>
<!-- Nib center slit -->
<path
android:strokeColor="#4338CA"
android:strokeWidth="2.5"
android:strokeAlpha="0.40"
android:fillColor="#00000000"
android:strokeLineCap="round"
android:pathData="M54,67 L54,88"/>
<!-- Ink drop at nib tip -->
<path
android:fillColor="#FFFFFF"
android:alpha="0.92"
android:pathData="M54,90 A4,4 0 1,1 54,98 A4,4 0 1,1 54,90Z"/>
</vector> </vector>
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" /> <background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" /> <foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon> </adaptive-icon>
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" /> <background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" /> <foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon> </adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

@@ -1,4 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<color name="ic_launcher_background">#111827</color> <!-- Fallback color for API < 26 (no adaptive icon support) -->
<color name="ic_launcher_background">#312E81</color>
</resources> </resources>
+1 -1
View File
@@ -61,7 +61,7 @@ Valor actual:
{ {
"expo": { "expo": {
"extra": { "extra": {
"apiBaseUrl": "http://192.168.222.72:4000" "apiBaseUrl": "http://192.168.222.40:4000"
} }
} }
} }
+1 -1
View File
@@ -16,7 +16,7 @@
"package": "net.linkedcorp.linkedsign" "package": "net.linkedcorp.linkedsign"
}, },
"extra": { "extra": {
"apiBaseUrl": "http://192.168.222.72:4000" "apiBaseUrl": "http://192.168.222.40:4000"
} }
} }
} }
+3
View File
@@ -1,6 +1,7 @@
import axios from 'axios'; import axios from 'axios';
import { API_BASE_URL } from '../config/env'; import { API_BASE_URL } from '../config/env';
import { loadSession, saveSession, clearSession } from '../storage/session'; import { loadSession, saveSession, clearSession } from '../storage/session';
import { notifySessionExpired } from '../utils/sessionEvents';
const client = axios.create({ const client = axios.create({
baseURL: API_BASE_URL, baseURL: API_BASE_URL,
@@ -38,6 +39,7 @@ client.interceptors.response.use(
const session = await loadSession(); const session = await loadSession();
if (!session.refreshToken) { if (!session.refreshToken) {
await clearSession(); await clearSession();
notifySessionExpired();
throw error; throw error;
} }
@@ -68,6 +70,7 @@ client.interceptors.response.use(
} catch (refreshError) { } catch (refreshError) {
resolveQueue(refreshError, null); resolveQueue(refreshError, null);
await clearSession(); await clearSession();
notifySessionExpired();
throw refreshError; throw refreshError;
} finally { } finally {
isRefreshing = false; isRefreshing = false;
+1 -1
View File
@@ -5,4 +5,4 @@ const extra = Constants.expoConfig?.extra || {};
export const API_BASE_URL = export const API_BASE_URL =
process.env.EXPO_PUBLIC_API_BASE_URL || process.env.EXPO_PUBLIC_API_BASE_URL ||
extra.apiBaseUrl || extra.apiBaseUrl ||
'http://192.168.222.72:4000'; 'http://192.168.222.40:4000';
+57 -7
View File
@@ -1,8 +1,9 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'; import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { Alert } from 'react-native'; import { Alert, AppState } from 'react-native';
import { changePassword as apiChangePassword, getMe, login as apiLogin, logout as apiLogout, register as apiRegister } from '../api/auth'; import { changePassword as apiChangePassword, getMe, login as apiLogin, logout as apiLogout, register as apiRegister } from '../api/auth';
import { initLocalDb, saveProfileCache } from '../storage/localDb'; import { initLocalDb, saveProfileCache } from '../storage/localDb';
import { clearSession, loadSession, saveSession } from '../storage/session'; import { clearSession, loadSession, saveSession } from '../storage/session';
import { SESSION_EXPIRED_MESSAGE, setSessionExpiredHandler } from '../utils/sessionEvents';
const AuthContext = createContext(null); const AuthContext = createContext(null);
@@ -11,6 +12,15 @@ export const AuthProvider = ({ children }) => {
const [tokens, setTokens] = useState({ accessToken: null, refreshToken: null }); const [tokens, setTokens] = useState({ accessToken: null, refreshToken: null });
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const clearLocalAuthState = useCallback(async ({ showAlert = false } = {}) => {
await clearSession();
setUser(null);
setTokens({ accessToken: null, refreshToken: null });
if (showAlert) {
Alert.alert('Sesión expirada', SESSION_EXPIRED_MESSAGE);
}
}, []);
useEffect(() => { useEffect(() => {
const bootstrap = async () => { const bootstrap = async () => {
initLocalDb(); initLocalDb();
@@ -27,14 +37,56 @@ export const AuthProvider = ({ children }) => {
setUser(data.data.user); setUser(data.data.user);
saveProfileCache(data.data.user); saveProfileCache(data.data.user);
} catch (error) { } catch (error) {
await clearSession(); await clearLocalAuthState();
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
bootstrap(); bootstrap();
}, []); }, [clearLocalAuthState]);
useEffect(() => {
return setSessionExpiredHandler(() => {
clearLocalAuthState({ showAlert: true });
});
}, [clearLocalAuthState]);
useEffect(() => {
if (!user || !tokens.accessToken) return undefined;
let timeoutId = null;
let isMounted = true;
const enforceExpiry = async ({ showAlert = false } = {}) => {
const session = await loadSession();
if (!session.accessToken || !session.expiresAt || session.expiresAt <= Date.now()) {
if (isMounted) {
await clearLocalAuthState({ showAlert });
}
return;
}
timeoutId = setTimeout(() => {
clearLocalAuthState({ showAlert: true });
}, Math.max(session.expiresAt - Date.now(), 0));
};
enforceExpiry();
const subscription = AppState.addEventListener('change', (state) => {
if (state === 'active') {
if (timeoutId) clearTimeout(timeoutId);
enforceExpiry({ showAlert: true });
}
});
return () => {
isMounted = false;
if (timeoutId) clearTimeout(timeoutId);
subscription.remove();
};
}, [clearLocalAuthState, tokens.accessToken, user]);
const login = useCallback(async (email, password) => { const login = useCallback(async (email, password) => {
const { data } = await apiLogin({ email, password }); const { data } = await apiLogin({ email, password });
@@ -59,11 +111,9 @@ export const AuthProvider = ({ children }) => {
} catch (error) { } catch (error) {
Alert.alert('Cerrar sesión', 'No se pudo notificar el cierre al backend. La sesión local se cerrará igual.'); Alert.alert('Cerrar sesión', 'No se pudo notificar el cierre al backend. La sesión local se cerrará igual.');
} finally { } finally {
await clearSession(); await clearLocalAuthState();
setUser(null);
setTokens({ accessToken: null, refreshToken: null });
} }
}, []); }, [clearLocalAuthState]);
const requestPasswordChange = useCallback(async ({ currentPassword, newPassword }) => { const requestPasswordChange = useCallback(async ({ currentPassword, newPassword }) => {
return apiChangePassword({ currentPassword, newPassword }); return apiChangePassword({ currentPassword, newPassword });
+18
View File
@@ -0,0 +1,18 @@
export const SESSION_EXPIRED_MESSAGE = 'Tu sesión expiró. Vuelve a iniciar sesión.';
let sessionExpiredHandler = null;
export const setSessionExpiredHandler = (handler) => {
sessionExpiredHandler = handler;
return () => {
if (sessionExpiredHandler === handler) {
sessionExpiredHandler = null;
}
};
};
export const notifySessionExpired = () => {
if (sessionExpiredHandler) {
sessionExpiredHandler();
}
};
+9 -1
View File
@@ -8,7 +8,8 @@ DB_USER=SignLinked
DB_PASSWORD= DB_PASSWORD=
JWT_SECRET=change_me JWT_SECRET=change_me
JWT_ACCESS_EXPIRES=15m JWT_ACCESS_EXPIRES=15m
JWT_REFRESH_EXPIRES=7d JWT_REFRESH_EXPIRES=15m
JWT_BIOMETRIC_EXPIRES=180d
STORAGE_PATH=/tmp/linkedsign_storage STORAGE_PATH=/tmp/linkedsign_storage
MAIL_HOST=smtp.gmail.com MAIL_HOST=smtp.gmail.com
MAIL_PORT=587 MAIL_PORT=587
@@ -17,5 +18,12 @@ MAIL_TLS_REJECT_UNAUTHORIZED=true
MAIL_USER= MAIL_USER=
MAIL_PASS= MAIL_PASS=
MAIL_FROM=noreply@linkedsign.local MAIL_FROM=noreply@linkedsign.local
LEGAL_CONTROLLER_NAME=LinkedCorp
LEGAL_PLATFORM_NAME=LinkedSign
LEGAL_CONTACT_EMAIL=noreply@linkedsign.local
LEGAL_CONTACT_PHONE=
LEGAL_CONTACT_ADDRESS=
LEGAL_WEBSITE=http://localhost:5173
LEGAL_RIGHTS_EMAIL=noreply@linkedsign.local
FRONTEND_URL=http://localhost:5173 FRONTEND_URL=http://localhost:5173
ANDROID_APK_PATH= ANDROID_APK_PATH=
+7 -7
View File
@@ -33,8 +33,8 @@ Variables importantes:
```env ```env
PORT=4000 PORT=4000
CORS_ORIGIN=http://192.168.222.72:5173 CORS_ORIGIN=http://192.168.222.40:5173
FRONTEND_URL=http://192.168.222.72:5173 FRONTEND_URL=http://192.168.222.40:5173
MAIL_HOST= MAIL_HOST=
MAIL_PORT=587 MAIL_PORT=587
MAIL_SECURE=false MAIL_SECURE=false
@@ -73,7 +73,7 @@ DB_NAME=
Registra usuario y envía correo de validación. Registra usuario y envía correo de validación.
```bash ```bash
curl -X POST http://192.168.222.72:4000/auth/register \ curl -X POST http://192.168.222.40:4000/auth/register \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{ -d '{
"email": "usuario@correo.com", "email": "usuario@correo.com",
@@ -86,7 +86,7 @@ curl -X POST http://192.168.222.72:4000/auth/register \
Inicia sesión. Si el correo no está validado, reenvía el enlace. Inicia sesión. Si el correo no está validado, reenvía el enlace.
```bash ```bash
curl -X POST http://192.168.222.72:4000/auth/login \ curl -X POST http://192.168.222.40:4000/auth/login \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{ -d '{
"email": "usuario@correo.com", "email": "usuario@correo.com",
@@ -117,7 +117,7 @@ Devuelve usuario autenticado y preferencias visuales de firma.
Sube un PDF original. Sube un PDF original.
```bash ```bash
curl -X POST http://192.168.222.72:4000/documents \ curl -X POST http://192.168.222.40:4000/documents \
-H "Authorization: Bearer TU_ACCESS_TOKEN" \ -H "Authorization: Bearer TU_ACCESS_TOKEN" \
-F "file=@/ruta/documento.pdf" -F "file=@/ruta/documento.pdf"
``` ```
@@ -137,7 +137,7 @@ Descarga un documento original.
Sube un certificado `.p12` o `.pfx`. Sube un certificado `.p12` o `.pfx`.
```bash ```bash
curl -X POST http://192.168.222.72:4000/certificates \ curl -X POST http://192.168.222.40:4000/certificates \
-H "Authorization: Bearer TU_ACCESS_TOKEN" \ -H "Authorization: Bearer TU_ACCESS_TOKEN" \
-F "file=@/ruta/certificado.p12" \ -F "file=@/ruta/certificado.p12" \
-F "alias=MPGH 2026" \ -F "alias=MPGH 2026" \
@@ -155,7 +155,7 @@ Lista certificados del usuario, incluyendo datos de cadena embebida.
Firma un documento con un certificado del usuario. Firma un documento con un certificado del usuario.
```bash ```bash
curl -X POST http://192.168.222.72:4000/signatures/sign \ curl -X POST http://192.168.222.40:4000/signatures/sign \
-H "Authorization: Bearer TU_ACCESS_TOKEN" \ -H "Authorization: Bearer TU_ACCESS_TOKEN" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{ -d '{
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "linkedsign-backend", "name": "linkedsign-backend",
"version": "1.0.1", "version": "1.2.35",
"private": true, "private": true,
"description": "Initial backend for LinkedSign", "description": "Initial backend for LinkedSign",
"main": "src/server.js", "main": "src/server.js",
+1
View File
@@ -16,6 +16,7 @@ app.use(helmet());
app.use( app.use(
cors({ cors({
origin: env.corsOrigin, origin: env.corsOrigin,
credentials: true,
}) })
); );
app.use(morgan(env.nodeEnv === 'production' ? 'combined' : 'dev')); app.use(morgan(env.nodeEnv === 'production' ? 'combined' : 'dev'));
+67 -1
View File
@@ -3,6 +3,58 @@ const dotenv = require('dotenv');
dotenv.config({ path: path.resolve(process.cwd(), '.env') }); dotenv.config({ path: path.resolve(process.cwd(), '.env') });
const requireEnv = (name) => {
const value = process.env[name];
if (!value) {
throw new Error(`Missing required environment variable: ${name}`);
}
return value;
};
const parseDurationMs = (value, fallbackMs) => {
if (!value) return fallbackMs;
const match = String(value).trim().match(/^(\d+)(ms|s|m|h|d)?$/i);
if (!match) return fallbackMs;
const amount = Number(match[1]);
const unit = (match[2] || 'ms').toLowerCase();
const multipliers = {
ms: 1,
s: 1000,
m: 60 * 1000,
h: 60 * 60 * 1000,
d: 24 * 60 * 60 * 1000,
};
return amount * multipliers[unit];
};
if (process.env.NODE_ENV === 'production') {
[
'JWT_SECRET',
'CORS_ORIGIN',
'FRONTEND_URL',
'DB_HOST',
'DB_NAME',
'DB_USER',
'DB_PASSWORD',
'STORAGE_PATH',
'MAIL_HOST',
'MAIL_PORT',
'MAIL_USER',
'MAIL_PASS',
'MAIL_FROM',
].forEach(requireEnv);
if (process.env.JWT_SECRET === 'change_me') {
throw new Error('JWT_SECRET cannot use the default insecure value in production.');
}
if ((process.env.FRONTEND_URL || '').includes('localhost')) {
throw new Error('FRONTEND_URL must not point to localhost in production.');
}
}
const env = { const env = {
port: Number(process.env.PORT) || 4000, port: Number(process.env.PORT) || 4000,
nodeEnv: process.env.NODE_ENV || 'development', nodeEnv: process.env.NODE_ENV || 'development',
@@ -19,7 +71,10 @@ const env = {
jwt: { jwt: {
secret: process.env.JWT_SECRET || 'change_me', secret: process.env.JWT_SECRET || 'change_me',
accessExpires: process.env.JWT_ACCESS_EXPIRES || '15m', accessExpires: process.env.JWT_ACCESS_EXPIRES || '15m',
refreshExpires: process.env.JWT_REFRESH_EXPIRES || '7d', refreshExpires: process.env.JWT_REFRESH_EXPIRES || '15m',
refreshExpiresMs: parseDurationMs(process.env.JWT_REFRESH_EXPIRES || '15m', 15 * 60 * 1000),
biometricExpires: process.env.JWT_BIOMETRIC_EXPIRES || '180d',
biometricExpiresMs: parseDurationMs(process.env.JWT_BIOMETRIC_EXPIRES || '180d', 180 * 24 * 60 * 60 * 1000),
}, },
storagePath: process.env.STORAGE_PATH || '/tmp/linkedsign_storage', storagePath: process.env.STORAGE_PATH || '/tmp/linkedsign_storage',
@@ -39,7 +94,18 @@ const env = {
from: process.env.MAIL_FROM || 'noreply@linkedsign.local', from: process.env.MAIL_FROM || 'noreply@linkedsign.local',
}, },
legal: {
controllerName: process.env.LEGAL_CONTROLLER_NAME || 'LinkedCorp',
platformName: process.env.LEGAL_PLATFORM_NAME || 'LinkedSign',
contactEmail: process.env.LEGAL_CONTACT_EMAIL || process.env.MAIL_FROM || 'sign@linkedcorp.net',
contactPhone: process.env.LEGAL_CONTACT_PHONE || 'No informado en este entorno',
contactAddress: process.env.LEGAL_CONTACT_ADDRESS || 'No informado en este entorno',
website: process.env.LEGAL_WEBSITE || process.env.FRONTEND_URL || 'http://localhost:5173',
rightsEmail: process.env.LEGAL_RIGHTS_EMAIL || process.env.MAIL_FROM || 'sign@linkedcorp.net',
},
frontendUrl: process.env.FRONTEND_URL || 'http://localhost:5173', frontendUrl: process.env.FRONTEND_URL || 'http://localhost:5173',
refreshCookieName: process.env.REFRESH_COOKIE_NAME || 'linkedsign_refresh',
androidApkPath: androidApkPath:
process.env.ANDROID_APK_PATH || path.resolve(process.cwd(), '..', 'android', 'apk', 'LinkedSign-debug.apk'), process.env.ANDROID_APK_PATH || path.resolve(process.cwd(), '..', 'android', 'apk', 'LinkedSign-debug.apk'),
}; };
+28 -2
View File
@@ -1,6 +1,6 @@
const path = require('path'); const path = require('path');
const multer = require('multer'); const multer = require('multer');
const { getDocumentPath, getCertificatePath } = require('../utils/storagePath'); const { getDocumentPath, getCertificatePath, getTempUploadPath } = require('../utils/storagePath');
const documentStorage = multer.diskStorage({ const documentStorage = multer.diskStorage({
destination: async (req, file, cb) => { destination: async (req, file, cb) => {
@@ -31,6 +31,20 @@ const certificateStorage = multer.diskStorage({
}, },
}); });
const tempCertificateStorage = multer.diskStorage({
destination: async (req, file, cb) => {
try {
cb(null, await getTempUploadPath('certificates'));
} catch (err) {
cb(err);
}
},
filename: (req, file, cb) => {
const safe = path.basename(file.originalname || 'cert.p12').replace(/[^a-zA-Z0-9._-]/g, '_');
cb(null, `${Date.now()}-${safe}`);
},
});
const uploadDocument = multer({ const uploadDocument = multer({
storage: documentStorage, storage: documentStorage,
limits: { fileSize: 20 * 1024 * 1024 }, limits: { fileSize: 20 * 1024 * 1024 },
@@ -54,4 +68,16 @@ const uploadCertificate = multer({
}, },
}); });
module.exports = { uploadDocument, uploadCertificate }; const uploadTempCertificate = multer({
storage: tempCertificateStorage,
limits: { fileSize: 5 * 1024 * 1024 },
fileFilter: (req, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase();
if (!['.p12', '.pfx'].includes(ext)) {
return cb(new Error('Only P12/PFX certificate files are allowed.'));
}
cb(null, true);
},
});
module.exports = { uploadDocument, uploadCertificate, uploadTempCertificate };
+12 -2
View File
@@ -3,7 +3,12 @@ const { verify } = require('../utils/jwt');
const authenticate = (req, res, next) => { const authenticate = (req, res, next) => {
const authHeader = req.headers['authorization']; const authHeader = req.headers['authorization'];
if (!authHeader || !authHeader.startsWith('Bearer ')) { if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ success: false, data: null, error: 'Authorization token required.' }); return res.status(401).json({
success: false,
data: null,
error: 'La sesión expiró. Vuelve a iniciar sesión.',
code: 'SESSION_EXPIRED',
});
} }
const token = authHeader.slice(7); const token = authHeader.slice(7);
@@ -12,7 +17,12 @@ const authenticate = (req, res, next) => {
req.user = { id: decoded.sub, email: decoded.email, role: decoded.role, name: decoded.name }; req.user = { id: decoded.sub, email: decoded.email, role: decoded.role, name: decoded.name };
return next(); return next();
} catch { } catch {
return res.status(401).json({ success: false, data: null, error: 'Invalid or expired token.' }); return res.status(401).json({
success: false,
data: null,
error: 'La sesión expiró. Vuelve a iniciar sesión.',
code: 'SESSION_EXPIRED',
});
} }
}; };
+18
View File
@@ -1,3 +1,4 @@
const auditService = require('../modules/audit/audit.service');
const { sendError } = require('../utils/apiResponse'); const { sendError } = require('../utils/apiResponse');
const errorHandler = (err, req, res, next) => { const errorHandler = (err, req, res, next) => {
@@ -14,6 +15,23 @@ const errorHandler = (err, req, res, next) => {
? (err.message || 'Request error') ? (err.message || 'Request error')
: (statusCode >= 500 ? 'Internal server error' : err.message || 'Request error'); : (statusCode >= 500 ? 'Internal server error' : err.message || 'Request error');
if (req.path !== '/health' && req.path !== '/api/health') {
auditService.logEvent({
type: 'PROCESS_ERROR',
userId: req.user?.id || null,
email: req.user?.email || req.body?.email || null,
ip: req.ip?.replace?.('::ffff:', '') || null,
userAgent: req.headers['user-agent'] || null,
metadata: {
method: req.method,
path: req.originalUrl || req.path,
statusCode,
message: err.message || null,
code: err.code || null,
},
}).catch(() => {});
}
if (err.code) { if (err.code) {
return res.status(statusCode).json({ return res.status(statusCode).json({
success: false, success: false,
@@ -0,0 +1,43 @@
const auditService = require('./audit.service');
const { sendSuccess } = require('../../utils/apiResponse');
const listMine = async (req, res, next) => {
try {
const result = await auditService.getEvents({
userId: req.user.id,
role: req.user.role,
page: req.query.page,
limit: req.query.limit,
type: req.query.type,
});
return sendSuccess(res, result, 200);
} catch (error) {
return next(error);
}
};
const purgeMine = async (req, res, next) => {
try {
if (!req.body?.certPassword) {
const error = new Error('Debes ingresar la contraseña de la firma registrada.');
error.statusCode = 400;
throw error;
}
const result = await auditService.purgeUserEvents({
actorUserId: req.user.id,
actorEmail: req.user.email,
actorRole: req.user.role,
certPassword: req.body.certPassword,
ip: req.ip?.replace('::ffff:', '') || null,
userAgent: req.headers['user-agent'] || null,
});
return sendSuccess(res, result, 200);
} catch (error) {
return next(error);
}
};
module.exports = {
listMine,
purgeMine,
};
+28 -4
View File
@@ -30,15 +30,39 @@ const saveEvent = async (event) => {
} }
}; };
const getEvents = async ({ type, userId, limit = 100, offset = 0 } = {}) => { const getEvents = async ({ type, userId, limit = 100, offset = 0, types = [] } = {}) => {
let sql = 'SELECT * FROM audit_logs WHERE 1=1'; let sql = 'SELECT * FROM audit_logs WHERE 1=1';
let countSql = 'SELECT COUNT(*) AS total FROM audit_logs WHERE 1=1';
const params = []; const params = [];
const countParams = [];
if (type) { sql += ' AND type = ?'; params.push(type); } if (type) { sql += ' AND type = ?'; params.push(type); }
if (userId) { sql += ' AND user_id = ?'; params.push(userId); } if (type) { countSql += ' AND type = ?'; countParams.push(type); }
if (types.length > 0) {
const placeholders = types.map(() => '?').join(', ');
sql += ` AND type IN (${placeholders})`;
countSql += ` AND type IN (${placeholders})`;
params.push(...types);
countParams.push(...types);
}
if (userId) {
sql += ' AND user_id = ?';
countSql += ' AND user_id = ?';
params.push(userId);
countParams.push(userId);
}
sql += ' ORDER BY created_at DESC LIMIT ? OFFSET ?'; sql += ' ORDER BY created_at DESC LIMIT ? OFFSET ?';
params.push(limit, offset); params.push(limit, offset);
const [rows] = await db.execute(sql, params); const [rows] = await db.execute(sql, params);
return rows; const [countRows] = await db.execute(countSql, countParams);
return {
rows,
total: countRows[0]?.total || 0,
};
}; };
module.exports = { saveEvent, getEvents }; const deleteEventsByUserId = async (userId) => {
const [result] = await db.execute('DELETE FROM audit_logs WHERE user_id = ?', [userId]);
return result.affectedRows || 0;
};
module.exports = { saveEvent, getEvents, deleteEventsByUserId };
+10
View File
@@ -0,0 +1,10 @@
const express = require('express');
const authenticate = require('../../middlewares/authenticate');
const auditController = require('./audit.controller');
const router = express.Router();
router.get('/mine', authenticate, auditController.listMine);
router.post('/mine/purge', authenticate, auditController.purgeMine);
module.exports = router;
+69 -3
View File
@@ -4,8 +4,74 @@ const logEvent = async ({ type, userId = null, email = null, ip = null, userAgen
await auditRepository.saveEvent({ type, userId, email, ip, userAgent, metadata }); await auditRepository.saveEvent({ type, userId, email, ip, userAgent, metadata });
}; };
const getEvents = async (filters) => { const ADMIN_VISIBLE_TYPES = ['AUDIT_LOGS_PURGED'];
return auditRepository.getEvents(filters);
const getEvents = async (filters = {}) => {
const limit = Math.min(Math.max(Number(filters.limit) || 15, 1), 50);
const page = Math.max(Number(filters.page) || 1, 1);
const offset = (page - 1) * limit;
const types = filters.role === 'admin' ? ADMIN_VISIBLE_TYPES : [];
const result = await auditRepository.getEvents({
type: filters.type || null,
userId: filters.role === 'admin' ? null : (filters.userId || null),
limit,
offset,
types,
});
return {
rows: result.rows.map((row) => ({
...row,
metadata: row.metadata ? JSON.parse(row.metadata) : null,
})),
pagination: {
page,
limit,
total: result.total,
totalPages: Math.max(1, Math.ceil(result.total / limit)),
},
};
}; };
module.exports = { logEvent, getEvents }; const purgeUserEvents = async ({ actorUserId, actorEmail, actorRole, certPassword, ip, userAgent }) => {
// Lazy-load to avoid the circular dependency:
// audit.service -> certificates.service -> audit.service
const certificatesService = require('../certificates/certificates.service');
if (actorRole === 'admin') {
const error = new Error('La cuenta administradora no puede eliminar logs.');
error.statusCode = 403;
throw error;
}
const activeCertificate = await certificatesService.getLatestActiveForUser(actorUserId);
if (!activeCertificate) {
const error = new Error('No existe un certificado activo asociado a esta cuenta.');
error.statusCode = 404;
throw error;
}
await certificatesService.loadForSigning(activeCertificate.id, actorUserId, certPassword);
const deletedCount = await auditRepository.deleteEventsByUserId(actorUserId);
await auditRepository.saveEvent({
type: 'AUDIT_LOGS_PURGED',
userId: null,
email: actorEmail,
ip,
userAgent,
metadata: {
purgedUserId: actorUserId,
purgedEmail: actorEmail,
deletedCount,
certificateId: activeCertificate.id,
},
});
return { deletedCount };
};
module.exports = { logEvent, getEvents, purgeUserEvents };
+112 -9
View File
@@ -1,17 +1,57 @@
const authService = require('./auth.service'); const authService = require('./auth.service');
const usersService = require('../users/users.service'); const usersService = require('../users/users.service');
const { sendSuccess } = require('../../utils/apiResponse'); const { sendSuccess } = require('../../utils/apiResponse');
const env = require('../../config/env');
const getContext = (req) => ({ const getContext = (req) => ({
ip: req.ip?.replace('::ffff:', '') || null, ip: req.ip?.replace('::ffff:', '') || null,
userAgent: req.headers['user-agent'] || null, userAgent: req.headers['user-agent'] || null,
}); });
const getRefreshCookieOptions = () => ({
httpOnly: true,
secure: env.nodeEnv === 'production',
sameSite: 'lax',
path: '/',
maxAge: env.jwt.refreshExpiresMs,
});
const readRefreshToken = (req) => {
const cookieHeader = req.headers.cookie || '';
const cookies = Object.fromEntries(
cookieHeader
.split(';')
.map((entry) => entry.trim())
.filter(Boolean)
.map((entry) => {
const separatorIndex = entry.indexOf('=');
if (separatorIndex === -1) return [entry, ''];
return [
decodeURIComponent(entry.slice(0, separatorIndex)),
decodeURIComponent(entry.slice(separatorIndex + 1)),
];
})
);
return cookies[env.refreshCookieName] || req.body?.refreshToken || null;
};
const register = async (req, res, next) => { const register = async (req, res, next) => {
try { try {
const result = await authService.register(req.body); if (!req.file) {
const error = new Error('Debes cargar un certificado P12 o PFX.');
error.statusCode = 400;
throw error;
}
const result = await authService.register({
email: req.body.email,
password: req.body.password,
certPassword: req.body.certPassword,
certificateAlias: req.body.certificateAlias,
certificateFile: req.file,
}, getContext(req));
return sendSuccess(res, { return sendSuccess(res, {
message: 'Registration successful. Please check your email to verify your account.', message: 'Registro exitoso. Revisa tu correo para verificar tu cuenta.',
userId: result.userId, userId: result.userId,
}, 201); }, 201);
} catch (err) { } catch (err) {
@@ -22,6 +62,7 @@ const register = async (req, res, next) => {
const login = async (req, res, next) => { const login = async (req, res, next) => {
try { try {
const result = await authService.login(req.body, getContext(req)); const result = await authService.login(req.body, getContext(req));
res.cookie(env.refreshCookieName, result.refreshToken, getRefreshCookieOptions());
return sendSuccess(res, result, 200); return sendSuccess(res, result, 200);
} catch (err) { } catch (err) {
return next(err); return next(err);
@@ -30,7 +71,18 @@ const login = async (req, res, next) => {
const refresh = async (req, res, next) => { const refresh = async (req, res, next) => {
try { try {
const result = await authService.refresh(req.body.refreshToken); const result = await authService.refresh(readRefreshToken(req));
res.cookie(env.refreshCookieName, result.refreshToken, getRefreshCookieOptions());
return sendSuccess(res, result, 200);
} catch (err) {
return next(err);
}
};
const biometricLogin = async (req, res, next) => {
try {
const result = await authService.biometricLogin(req.body.biometricToken);
res.cookie(env.refreshCookieName, result.refreshToken, getRefreshCookieOptions());
return sendSuccess(res, result, 200); return sendSuccess(res, result, 200);
} catch (err) { } catch (err) {
return next(err); return next(err);
@@ -39,8 +91,9 @@ const refresh = async (req, res, next) => {
const logout = async (req, res, next) => { const logout = async (req, res, next) => {
try { try {
await authService.logout(req.body.refreshToken); await authService.logout(readRefreshToken(req));
return sendSuccess(res, { message: 'Logged out.' }, 200); res.clearCookie(env.refreshCookieName, getRefreshCookieOptions());
return sendSuccess(res, { message: 'Sesión cerrada.' }, 200);
} catch (err) { } catch (err) {
return next(err); return next(err);
} }
@@ -49,7 +102,7 @@ const logout = async (req, res, next) => {
const changePassword = async (req, res, next) => { const changePassword = async (req, res, next) => {
try { try {
await authService.changePassword(req.user.id, req.body, getContext(req)); await authService.changePassword(req.user.id, req.body, getContext(req));
return sendSuccess(res, { message: 'Password change requested. Please confirm the change from the email sent to your account.' }, 200); return sendSuccess(res, { message: 'Cambio de contraseña solicitado. Confirma el cambio desde el correo enviado a tu cuenta.' }, 200);
} catch (err) { } catch (err) {
return next(err); return next(err);
} }
@@ -58,7 +111,25 @@ const changePassword = async (req, res, next) => {
const confirmPasswordChange = async (req, res, next) => { const confirmPasswordChange = async (req, res, next) => {
try { try {
await authService.confirmPasswordChange(req.params.token); await authService.confirmPasswordChange(req.params.token);
return sendSuccess(res, { message: 'Password changed successfully.' }, 200); return sendSuccess(res, { message: 'Contraseña cambiada correctamente.' }, 200);
} catch (err) {
return next(err);
}
};
const getUnlockAccountRequest = async (req, res, next) => {
try {
const result = await authService.getUnlockAccountRequest(req.params.token);
return sendSuccess(res, result, 200);
} catch (err) {
return next(err);
}
};
const requestUnlockPasswordChange = async (req, res, next) => {
try {
await authService.validatePasswordResetBySignature(req.params.token, req.body, getContext(req));
return sendSuccess(res, { message: 'Te enviamos un correo para confirmar el cambio de contraseña y desbloquear la cuenta.' }, 200);
} catch (err) { } catch (err) {
return next(err); return next(err);
} }
@@ -67,7 +138,7 @@ const confirmPasswordChange = async (req, res, next) => {
const verifyEmail = async (req, res, next) => { const verifyEmail = async (req, res, next) => {
try { try {
await authService.verifyEmail(req.params.token); await authService.verifyEmail(req.params.token);
return sendSuccess(res, { message: 'Email verified successfully.' }, 200); return sendSuccess(res, { message: 'Correo verificado correctamente.' }, 200);
} catch (err) { } catch (err) {
return next(err); return next(err);
} }
@@ -87,4 +158,36 @@ const me = async (req, res, next) => {
} }
}; };
module.exports = { register, login, refresh, logout, verifyEmail, me, changePassword, confirmPasswordChange }; const issueBiometricToken = async (req, res, next) => {
try {
const result = await authService.issueBiometricToken(req.user.id);
return sendSuccess(res, result, 200);
} catch (err) {
return next(err);
}
};
const revokeBiometricToken = async (req, res, next) => {
try {
await authService.revokeBiometricToken(req.user.id);
return sendSuccess(res, { message: 'Acceso biométrico desactivado.' }, 200);
} catch (err) {
return next(err);
}
};
module.exports = {
register,
login,
refresh,
biometricLogin,
logout,
verifyEmail,
me,
issueBiometricToken,
revokeBiometricToken,
changePassword,
confirmPasswordChange,
getUnlockAccountRequest,
requestUnlockPasswordChange,
};
+163 -6
View File
@@ -1,8 +1,52 @@
const db = require('../../config/db'); const db = require('../../config/db');
const crypto = require('crypto');
const hashRefreshToken = (token) => crypto.createHash('sha256').update(String(token)).digest('hex');
const ensureSchema = async () => {
const columns = [
{ name: 'failed_login_attempts', sql: 'ALTER TABLE users ADD COLUMN failed_login_attempts INT NOT NULL DEFAULT 0' },
{ name: 'locked_at', sql: 'ALTER TABLE users ADD COLUMN locked_at DATETIME NULL DEFAULT NULL' },
{ name: 'unlock_token', sql: 'ALTER TABLE users ADD COLUMN unlock_token VARCHAR(255) NULL DEFAULT NULL' },
{ name: 'unlock_token_expires', sql: 'ALTER TABLE users ADD COLUMN unlock_token_expires DATETIME NULL DEFAULT NULL' },
];
for (const column of columns) {
const [rows] = await db.execute(
`SELECT COUNT(*) AS count
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'users'
AND COLUMN_NAME = ?`,
[column.name]
);
if (!rows[0]?.count) {
await db.execute(column.sql);
}
}
await db.execute(`
CREATE TABLE IF NOT EXISTS biometric_tokens (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
token VARCHAR(255) NOT NULL,
expires_at DATETIME NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uniq_biometric_token (token),
KEY idx_biometric_user_id (user_id),
CONSTRAINT fk_biometric_tokens_user
FOREIGN KEY (user_id) REFERENCES users(id)
ON DELETE CASCADE
)
`);
};
const findByEmail = async (email) => { const findByEmail = async (email) => {
const [rows] = await db.execute( const [rows] = await db.execute(
'SELECT id, name, email, password_hash, role, is_verified, is_active FROM users WHERE email = ?', `SELECT id, name, email, password_hash, role, is_verified, is_active,
failed_login_attempts, locked_at, unlock_token, unlock_token_expires
FROM users WHERE email = ?`,
[email] [email]
); );
return rows[0] || null; return rows[0] || null;
@@ -18,12 +62,25 @@ const findById = async (id) => {
const findAuthUserById = async (id) => { const findAuthUserById = async (id) => {
const [rows] = await db.execute( const [rows] = await db.execute(
'SELECT id, name, email, password_hash, role, is_verified, is_active FROM users WHERE id = ?', `SELECT id, name, email, password_hash, role, is_verified, is_active,
failed_login_attempts, locked_at, unlock_token, unlock_token_expires
FROM users WHERE id = ?`,
[id] [id]
); );
return rows[0] || null; return rows[0] || null;
}; };
const findByUnlockToken = async (token) => {
const [rows] = await db.execute(
`SELECT id, name, email, password_hash, role, is_verified, is_active,
failed_login_attempts, locked_at, unlock_token, unlock_token_expires
FROM users
WHERE unlock_token = ? AND unlock_token_expires > NOW()`,
[token]
);
return rows[0] || null;
};
const createUser = async ({ name, email, passwordHash, verificationToken, verificationTokenExpires }) => { const createUser = async ({ name, email, passwordHash, verificationToken, verificationTokenExpires }) => {
const [result] = await db.execute( const [result] = await db.execute(
`INSERT INTO users (name, email, password_hash, verification_token, verification_token_expires) `INSERT INTO users (name, email, password_hash, verification_token, verification_token_expires)
@@ -53,29 +110,113 @@ const updateVerificationToken = async (userId, token, expiresAt) => {
); );
}; };
const recordFailedLoginAttempt = async (userId) => {
await db.execute(
'UPDATE users SET failed_login_attempts = COALESCE(failed_login_attempts, 0) + 1 WHERE id = ?',
[userId]
);
};
const setAccountLocked = async (userId, unlockToken, unlockTokenExpires) => {
await db.execute(
`UPDATE users
SET failed_login_attempts = 3,
locked_at = NOW(),
unlock_token = ?,
unlock_token_expires = ?
WHERE id = ?`,
[unlockToken, unlockTokenExpires, userId]
);
};
const resetFailedLoginAttempts = async (userId) => {
await db.execute(
`UPDATE users
SET failed_login_attempts = 0
WHERE id = ?`,
[userId]
);
};
const clearAccountLock = async (userId) => {
await db.execute(
`UPDATE users
SET failed_login_attempts = 0,
locked_at = NULL,
unlock_token = NULL,
unlock_token_expires = NULL
WHERE id = ?`,
[userId]
);
};
const updateUnlockToken = async (userId, unlockToken, unlockTokenExpires) => {
await db.execute(
`UPDATE users
SET unlock_token = ?,
unlock_token_expires = ?,
locked_at = COALESCE(locked_at, NOW())
WHERE id = ?`,
[unlockToken, unlockTokenExpires, userId]
);
};
const saveRefreshToken = async (userId, token, expiresAt) => { const saveRefreshToken = async (userId, token, expiresAt) => {
const hashedToken = hashRefreshToken(token);
await db.execute( await db.execute(
'INSERT INTO refresh_tokens (user_id, token, expires_at) VALUES (?, ?, ?)', 'INSERT INTO refresh_tokens (user_id, token, expires_at) VALUES (?, ?, ?)',
[userId, token, expiresAt] [userId, hashedToken, expiresAt]
); );
}; };
const findRefreshToken = async (token) => { const findRefreshToken = async (token) => {
const hashedToken = hashRefreshToken(token);
const [rows] = await db.execute( const [rows] = await db.execute(
'SELECT rt.*, u.id as uid, u.email, u.name, u.role, u.is_verified, u.is_active FROM refresh_tokens rt JOIN users u ON rt.user_id = u.id WHERE rt.token = ? AND rt.expires_at > NOW()', 'SELECT rt.*, u.id as uid, u.email, u.name, u.role, u.is_verified, u.is_active FROM refresh_tokens rt JOIN users u ON rt.user_id = u.id WHERE rt.token IN (?, ?) AND rt.expires_at > NOW()',
[token] [hashedToken, token]
); );
return rows[0] || null; return rows[0] || null;
}; };
const deleteRefreshToken = async (token) => { const deleteRefreshToken = async (token) => {
await db.execute('DELETE FROM refresh_tokens WHERE token = ?', [token]); const hashedToken = hashRefreshToken(token);
await db.execute('DELETE FROM refresh_tokens WHERE token IN (?, ?)', [hashedToken, token]);
}; };
const deleteUserRefreshTokens = async (userId) => { const deleteUserRefreshTokens = async (userId) => {
await db.execute('DELETE FROM refresh_tokens WHERE user_id = ?', [userId]); await db.execute('DELETE FROM refresh_tokens WHERE user_id = ?', [userId]);
}; };
const saveBiometricToken = async (userId, token, expiresAt) => {
const hashedToken = hashRefreshToken(token);
await db.execute('DELETE FROM biometric_tokens WHERE user_id = ?', [userId]);
await db.execute(
'INSERT INTO biometric_tokens (user_id, token, expires_at) VALUES (?, ?, ?)',
[userId, hashedToken, expiresAt]
);
};
const findBiometricToken = async (token) => {
const hashedToken = hashRefreshToken(token);
const [rows] = await db.execute(
`SELECT bt.*, u.id as uid, u.email, u.name, u.role, u.is_verified, u.is_active
FROM biometric_tokens bt
JOIN users u ON bt.user_id = u.id
WHERE bt.token = ? AND bt.expires_at > NOW()`,
[hashedToken]
);
return rows[0] || null;
};
const deleteBiometricToken = async (token) => {
const hashedToken = hashRefreshToken(token);
await db.execute('DELETE FROM biometric_tokens WHERE token = ?', [hashedToken]);
};
const deleteUserBiometricTokens = async (userId) => {
await db.execute('DELETE FROM biometric_tokens WHERE user_id = ?', [userId]);
};
const setResetToken = async (userId, token, expiresAt) => { const setResetToken = async (userId, token, expiresAt) => {
await db.execute( await db.execute(
'UPDATE users SET reset_token = ?, reset_token_expires = ? WHERE id = ?', 'UPDATE users SET reset_token = ?, reset_token_expires = ? WHERE id = ?',
@@ -98,18 +239,34 @@ const updatePassword = async (userId, passwordHash) => {
); );
}; };
const deleteUser = async (userId) => {
await db.execute('DELETE FROM users WHERE id = ?', [userId]);
};
module.exports = { module.exports = {
ensureSchema,
findByEmail, findByEmail,
findById, findById,
findAuthUserById, findAuthUserById,
findByUnlockToken,
createUser, createUser,
verifyEmail, verifyEmail,
updateVerificationToken, updateVerificationToken,
recordFailedLoginAttempt,
setAccountLocked,
resetFailedLoginAttempts,
clearAccountLock,
updateUnlockToken,
saveRefreshToken, saveRefreshToken,
findRefreshToken, findRefreshToken,
deleteRefreshToken, deleteRefreshToken,
deleteUserRefreshTokens, deleteUserRefreshTokens,
saveBiometricToken,
findBiometricToken,
deleteBiometricToken,
deleteUserBiometricTokens,
setResetToken, setResetToken,
findByResetToken, findByResetToken,
updatePassword, updatePassword,
deleteUser,
}; };
+8 -2
View File
@@ -1,17 +1,23 @@
const express = require('express'); const express = require('express');
const authController = require('./auth.controller'); const authController = require('./auth.controller');
const { validate, registerSchema, loginSchema, refreshSchema, changePasswordSchema } = require('./auth.validator'); const { validate, validateRegisterMultipart, loginSchema, refreshSchema, biometricTokenSchema, changePasswordSchema, unlockAccountSchema } = require('./auth.validator');
const authenticate = require('../../middlewares/authenticate'); const authenticate = require('../../middlewares/authenticate');
const { uploadTempCertificate } = require('../../config/multer');
const router = express.Router(); const router = express.Router();
router.post('/register', validate(registerSchema), authController.register); router.post('/register', uploadTempCertificate.single('certificateFile'), validateRegisterMultipart, authController.register);
router.post('/login', validate(loginSchema), authController.login); router.post('/login', validate(loginSchema), authController.login);
router.post('/refresh', validate(refreshSchema), authController.refresh); router.post('/refresh', validate(refreshSchema), authController.refresh);
router.post('/biometric-login', validate(biometricTokenSchema), authController.biometricLogin);
router.post('/logout', authController.logout); router.post('/logout', authController.logout);
router.get('/verify/:token', authController.verifyEmail); router.get('/verify/:token', authController.verifyEmail);
router.get('/confirm-password-change/:token', authController.confirmPasswordChange); router.get('/confirm-password-change/:token', authController.confirmPasswordChange);
router.get('/unlock-account/:token', authController.getUnlockAccountRequest);
router.post('/unlock-account/:token/request-password-change', validate(unlockAccountSchema), authController.requestUnlockPasswordChange);
router.get('/me', authenticate, authController.me); router.get('/me', authenticate, authController.me);
router.post('/biometric-token', authenticate, authController.issueBiometricToken);
router.delete('/biometric-token', authenticate, authController.revokeBiometricToken);
router.patch('/change-password', authenticate, validate(changePasswordSchema), authController.changePassword); router.patch('/change-password', authenticate, validate(changePasswordSchema), authController.changePassword);
module.exports = router; module.exports = router;
+300 -16
View File
@@ -1,13 +1,22 @@
const bcrypt = require('bcrypt'); const bcrypt = require('bcrypt');
const { v4: uuidv4 } = require('uuid'); const { v4: uuidv4 } = require('uuid');
const crypto = require('crypto');
const authRepository = require('./auth.repository'); const authRepository = require('./auth.repository');
const passwordChangeRepository = require('./password-change.repository'); const passwordChangeRepository = require('./password-change.repository');
const auditService = require('../audit/audit.service'); const auditService = require('../audit/audit.service');
const usersService = require('../users/users.service'); const usersService = require('../users/users.service');
const mailService = require('../mail/mail.service'); const mailService = require('../mail/mail.service');
const certificatesService = require('../certificates/certificates.service');
const legalService = require('../legal/legal.service');
const { getCertificatePath } = require('../../utils/storagePath');
const { signAccess, signRefresh } = require('../../utils/jwt'); const { signAccess, signRefresh } = require('../../utils/jwt');
const fs = require('fs/promises');
const path = require('path');
const env = require('../../config/env');
const SALT_ROUNDS = 12; const SALT_ROUNDS = 12;
const MAX_FAILED_LOGIN_ATTEMPTS = 3;
const createBiometricToken = () => crypto.randomBytes(48).toString('hex');
const buildDisplayNameFromEmail = (email) => { const buildDisplayNameFromEmail = (email) => {
const localPart = String(email).split('@')[0] || 'Usuario'; const localPart = String(email).split('@')[0] || 'Usuario';
@@ -37,7 +46,25 @@ const issueVerificationEmail = async (user) => {
}; };
}; };
const register = async ({ email, password }) => { const issueUnlockEmail = async (user) => {
const unlockToken = uuidv4();
const unlockTokenExpires = new Date(Date.now() + 60 * 60 * 1000);
await authRepository.updateUnlockToken(user.id, unlockToken, unlockTokenExpires);
const mailResult = await mailService.sendAccountLockedEmail({
name: user.name || buildDisplayNameFromEmail(user.email),
email: user.email,
unlockToken,
});
return {
unlockToken,
unlockUrl: mailResult.unlockUrl,
};
};
const register = async ({ email, password, certPassword, certificateFile, certificateAlias }, context = {}) => {
const existing = await authRepository.findByEmail(email); const existing = await authRepository.findByEmail(email);
if (existing) { if (existing) {
const err = new Error('Email already registered.'); const err = new Error('Email already registered.');
@@ -57,17 +84,56 @@ const register = async ({ email, password }) => {
verificationToken, verificationToken,
verificationTokenExpires, verificationTokenExpires,
}); });
let mailResult;
let uploadedCertificate = null;
let legalDocuments = [];
try { try {
mailResult = await mailService.sendVerificationEmail({ const certificateDir = await getCertificatePath(userId);
const certificateFilename = path.basename(certificateFile.path);
const finalCertificatePath = path.join(certificateDir, certificateFilename);
await fs.rename(certificateFile.path, finalCertificatePath);
uploadedCertificate = await certificatesService.upload({
...certificateFile,
path: finalCertificatePath,
filename: certificateFilename,
}, certPassword, userId, certificateAlias || certificateFile.originalname, context);
const p12Buffer = await fs.readFile(finalCertificatePath);
legalDocuments = await legalService.issueInitialLegalPackage({
userId,
userName: name,
userEmail: email,
signerName: uploadedCertificate.subject_cn || name,
p12Buffer,
p12Password: certPassword,
context,
});
const attachments = legalDocuments.map((item) => ({
filename: item.signedFilename,
path: item.signedPath,
}));
await mailService.sendVerificationEmail({
name, name,
email, email,
verificationToken, verificationToken,
attachments,
}); });
} catch (error) { } catch (error) {
const err = new Error('The account was created, but the verification email could not be sent. Please contact support or try again later.'); await authRepository.deleteUser(userId);
err.statusCode = 503; try {
err.code = 'MAIL_SEND_FAILED'; if (certificateFile?.path) {
await fs.unlink(certificateFile.path);
}
} catch {}
const err = new Error('No se pudo completar el registro legal inicial. Revisa el certificado, la contraseña del P12 y vuelve a intentarlo.');
err.statusCode = error.statusCode || 503;
err.code = error.code || 'REGISTER_LEGAL_FLOW_FAILED';
err.message = error.message || err.message;
throw err; throw err;
} }
@@ -77,10 +143,16 @@ const register = async ({ email, password }) => {
email, email,
metadata: { metadata: {
verificationEmailSent: true, verificationEmailSent: true,
legalPackageCreated: true,
legalVersion: '1.1.0',
}, },
}); });
return { userId, verificationToken, verificationUrl: mailResult.verificationUrl }; return {
userId,
verificationToken,
legalDocuments: legalDocuments.map(({ signedPath, originalPath, ...item }) => item),
};
}; };
const login = async ({ email, password }, context = {}) => { const login = async ({ email, password }, context = {}) => {
@@ -88,25 +160,72 @@ const login = async ({ email, password }, context = {}) => {
if (!user || !user.is_active) { if (!user || !user.is_active) {
await auditService.logEvent({ type: 'LOGIN_FAILED', email, ip: context.ip, userAgent: context.userAgent }); await auditService.logEvent({ type: 'LOGIN_FAILED', email, ip: context.ip, userAgent: context.userAgent });
const err = new Error('Invalid credentials.'); const err = new Error('Credenciales inválidas.');
err.statusCode = 401; err.statusCode = 401;
throw err; throw err;
} }
if (user.locked_at) {
let unlockUrl = null;
try {
({ unlockUrl } = await issueUnlockEmail(user));
} catch {
const err = new Error('Tu cuenta está bloqueada y no se pudo reenviar el correo de desbloqueo. Contacta soporte.');
err.statusCode = 503;
err.code = 'UNLOCK_MAIL_SEND_FAILED';
throw err;
}
const err = new Error('Tu cuenta está bloqueada temporalmente. Te enviamos un enlace para desbloquear el sistema.');
err.statusCode = 423;
err.code = 'ACCOUNT_LOCKED';
err.data = { unlockUrl };
throw err;
}
const passwordMatch = await bcrypt.compare(password, user.password_hash); const passwordMatch = await bcrypt.compare(password, user.password_hash);
if (!passwordMatch) { if (!passwordMatch) {
await authRepository.recordFailedLoginAttempt(user.id);
const failedAttempts = Number(user.failed_login_attempts || 0) + 1;
await auditService.logEvent({ type: 'LOGIN_FAILED', userId: user.id, email, ip: context.ip, userAgent: context.userAgent }); await auditService.logEvent({ type: 'LOGIN_FAILED', userId: user.id, email, ip: context.ip, userAgent: context.userAgent });
const err = new Error('Invalid credentials.');
if (failedAttempts >= MAX_FAILED_LOGIN_ATTEMPTS) {
const unlockToken = uuidv4();
const unlockTokenExpires = new Date(Date.now() + 60 * 60 * 1000);
await authRepository.setAccountLocked(user.id, unlockToken, unlockTokenExpires);
try {
await mailService.sendAccountLockedEmail({
name: user.name || buildDisplayNameFromEmail(user.email),
email: user.email,
unlockToken,
});
} catch {
const err = new Error('Tu cuenta fue bloqueada por seguridad, pero no se pudo enviar el correo de desbloqueo. Contacta soporte.');
err.statusCode = 503;
err.code = 'UNLOCK_MAIL_SEND_FAILED';
throw err;
}
const err = new Error('Tu cuenta fue bloqueada por seguridad después de 3 intentos fallidos. Te enviamos un correo para desbloquearla.');
err.statusCode = 423;
err.code = 'ACCOUNT_LOCKED';
throw err;
}
const err = new Error('Credenciales inválidas.');
err.statusCode = 401; err.statusCode = 401;
throw err; throw err;
} }
await authRepository.resetFailedLoginAttempts(user.id);
if (!user.is_verified) { if (!user.is_verified) {
let verificationUrl = null; let verificationUrl = null;
try { try {
({ verificationUrl } = await issueVerificationEmail(user)); ({ verificationUrl } = await issueVerificationEmail(user));
} catch (error) { } catch (error) {
const err = new Error('Your email is not verified and the verification email could not be resent. Please contact support.'); const err = new Error('Tu correo no está verificado y no se pudo reenviar el correo de confirmación. Contacta soporte.');
err.statusCode = 503; err.statusCode = 503;
err.code = 'MAIL_SEND_FAILED'; err.code = 'MAIL_SEND_FAILED';
throw err; throw err;
@@ -119,7 +238,7 @@ const login = async ({ email, password }, context = {}) => {
userAgent: context.userAgent, userAgent: context.userAgent,
metadata: { source: 'login' }, metadata: { source: 'login' },
}); });
const err = new Error('Your email is not verified yet. A new verification link has been sent to your email.'); const err = new Error('Tu correo aún no está verificado. Te enviamos un nuevo enlace de confirmación.');
err.statusCode = 403; err.statusCode = 403;
err.code = 'EMAIL_NOT_VERIFIED'; err.code = 'EMAIL_NOT_VERIFIED';
err.data = { verificationUrl }; err.data = { verificationUrl };
@@ -130,7 +249,7 @@ const login = async ({ email, password }, context = {}) => {
const accessToken = signAccess(payload); const accessToken = signAccess(payload);
const refreshToken = signRefresh(payload); const refreshToken = signRefresh(payload);
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); const expiresAt = new Date(Date.now() + env.jwt.refreshExpiresMs);
await authRepository.saveRefreshToken(user.id, refreshToken, expiresAt); await authRepository.saveRefreshToken(user.id, refreshToken, expiresAt);
await auditService.logEvent({ type: 'LOGIN_SUCCESS', userId: user.id, email, ip: context.ip, userAgent: context.userAgent }); await auditService.logEvent({ type: 'LOGIN_SUCCESS', userId: user.id, email, ip: context.ip, userAgent: context.userAgent });
@@ -151,16 +270,23 @@ const login = async ({ email, password }, context = {}) => {
}; };
const refresh = async (token) => { const refresh = async (token) => {
if (!token) {
const err = new Error('La sesión expiró. Vuelve a iniciar sesión.');
err.statusCode = 401;
throw err;
}
const record = await authRepository.findRefreshToken(token); const record = await authRepository.findRefreshToken(token);
if (!record) { if (!record) {
const err = new Error('Invalid or expired refresh token.'); await authRepository.deleteRefreshToken(token);
const err = new Error('La sesión expiró. Vuelve a iniciar sesión.');
err.statusCode = 401; err.statusCode = 401;
throw err; throw err;
} }
if (!record.is_active) { if (!record.is_active) {
await authRepository.deleteUserRefreshTokens(record.user_id); await authRepository.deleteUserRefreshTokens(record.user_id);
const err = new Error('Session invalidated.'); const err = new Error('La sesión fue invalidada. Vuelve a iniciar sesión.');
err.statusCode = 401; err.statusCode = 401;
throw err; throw err;
} }
@@ -170,16 +296,86 @@ const refresh = async (token) => {
const payload = { sub: record.uid, email: record.email, role: record.role }; const payload = { sub: record.uid, email: record.email, role: record.role };
const accessToken = signAccess(payload); const accessToken = signAccess(payload);
const newRefreshToken = signRefresh(payload); const newRefreshToken = signRefresh(payload);
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); const expiresAt = new Date(Date.now() + env.jwt.refreshExpiresMs);
await authRepository.saveRefreshToken(record.uid, newRefreshToken, expiresAt); await authRepository.saveRefreshToken(record.uid, newRefreshToken, expiresAt);
return { accessToken, refreshToken: newRefreshToken }; return { accessToken, refreshToken: newRefreshToken };
}; };
const issueBiometricToken = async (userId) => {
const user = await authRepository.findAuthUserById(userId);
if (!user || !user.is_active) {
const err = new Error('User not found.');
err.statusCode = 404;
throw err;
}
const biometricToken = createBiometricToken();
const expiresAt = new Date(Date.now() + env.jwt.biometricExpiresMs);
await authRepository.saveBiometricToken(userId, biometricToken, expiresAt);
return { biometricToken };
};
const biometricLogin = async (token) => {
if (!token) {
const err = new Error('La sesión biométrica expiró. Inicia sesión con correo y contraseña.');
err.statusCode = 401;
err.code = 'BIOMETRIC_EXPIRED';
throw err;
}
const record = await authRepository.findBiometricToken(token);
if (!record) {
await authRepository.deleteBiometricToken(token);
const err = new Error('La sesión biométrica expiró. Inicia sesión con correo y contraseña.');
err.statusCode = 401;
err.code = 'BIOMETRIC_EXPIRED';
throw err;
}
if (!record.is_active) {
await authRepository.deleteUserBiometricTokens(record.user_id);
const err = new Error('La sesión biométrica fue invalidada. Inicia sesión con correo y contraseña.');
err.statusCode = 401;
err.code = 'BIOMETRIC_INVALIDATED';
throw err;
}
await authRepository.deleteBiometricToken(token);
const payload = { sub: record.uid, email: record.email, role: record.role, name: record.name };
const accessToken = signAccess(payload);
const refreshToken = signRefresh(payload);
const biometricToken = createBiometricToken();
await authRepository.saveRefreshToken(record.uid, refreshToken, new Date(Date.now() + env.jwt.refreshExpiresMs));
await authRepository.saveBiometricToken(record.uid, biometricToken, new Date(Date.now() + env.jwt.biometricExpiresMs));
const signaturePreferences = await usersService.getSignaturePreferences(record.uid);
return {
accessToken,
refreshToken,
biometricToken,
user: {
id: record.uid,
name: record.name,
email: record.email,
role: record.role,
signaturePreferences,
},
};
};
const logout = async (token) => { const logout = async (token) => {
if (!token) return;
await authRepository.deleteRefreshToken(token); await authRepository.deleteRefreshToken(token);
}; };
const revokeBiometricToken = async (userId) => {
await authRepository.deleteUserBiometricTokens(userId);
};
const changePassword = async (userId, { currentPassword, newPassword }, context = {}) => { const changePassword = async (userId, { currentPassword, newPassword }, context = {}) => {
const user = await authRepository.findAuthUserById(userId); const user = await authRepository.findAuthUserById(userId);
if (!user || !user.is_active) { if (!user || !user.is_active) {
@@ -230,6 +426,76 @@ const changePassword = async (userId, { currentPassword, newPassword }, context
return { confirmationToken }; return { confirmationToken };
}; };
const getUnlockAccountRequest = async (unlockToken) => {
const user = await authRepository.findByUnlockToken(unlockToken);
if (!user || !user.is_active) {
const err = new Error('El enlace de desbloqueo no es válido o expiró.');
err.statusCode = 400;
throw err;
}
const activeCertificate = await certificatesService.getLatestActiveForUser(user.id);
return {
email: user.email,
certificateAlias: activeCertificate?.alias || null,
certificateSubject: activeCertificate?.subject_cn || null,
};
};
const validatePasswordResetBySignature = async (unlockToken, { certPassword, newPassword }, context = {}) => {
const user = await authRepository.findByUnlockToken(unlockToken);
if (!user || !user.is_active) {
const err = new Error('El enlace de desbloqueo no es válido o expiró.');
err.statusCode = 400;
throw err;
}
const activeCertificate = await certificatesService.getLatestActiveForUser(user.id);
if (!activeCertificate) {
const err = new Error('No existe un certificado activo asociado a esta cuenta.');
err.statusCode = 404;
throw err;
}
await certificatesService.loadForSigning(activeCertificate.id, user.id, certPassword);
const isSamePassword = await bcrypt.compare(newPassword, user.password_hash);
if (isSamePassword) {
const err = new Error('La nueva contraseña debe ser diferente de la contraseña actual.');
err.statusCode = 400;
throw err;
}
const passwordHash = await bcrypt.hash(newPassword, SALT_ROUNDS);
const confirmationToken = uuidv4();
const expiresAt = new Date(Date.now() + 60 * 60 * 1000);
await passwordChangeRepository.saveRequest(confirmationToken, {
userId: user.id,
email: user.email,
passwordHash,
expiresAt: expiresAt.toISOString(),
source: 'account_unlock',
});
await mailService.sendPasswordChangeConfirmationEmail({
name: user.name || buildDisplayNameFromEmail(user.email),
email: user.email,
confirmationToken,
});
await auditService.logEvent({
type: 'ACCOUNT_UNLOCK_PASSWORD_CHANGE_REQUESTED',
userId: user.id,
email: user.email,
ip: context.ip,
userAgent: context.userAgent,
});
return { confirmationToken };
};
const confirmPasswordChange = async (token) => { const confirmPasswordChange = async (token) => {
const request = await passwordChangeRepository.findRequest(token); const request = await passwordChangeRepository.findRequest(token);
if (!request) { if (!request) {
@@ -254,6 +520,11 @@ const confirmPasswordChange = async (token) => {
} }
await authRepository.updatePassword(request.userId, request.passwordHash); await authRepository.updatePassword(request.userId, request.passwordHash);
await authRepository.deleteUserRefreshTokens(request.userId);
await authRepository.deleteUserBiometricTokens(request.userId);
if (request.source === 'account_unlock') {
await authRepository.clearAccountLock(request.userId);
}
await passwordChangeRepository.deleteRequest(token); await passwordChangeRepository.deleteRequest(token);
await auditService.logEvent({ await auditService.logEvent({
@@ -283,4 +554,17 @@ const verifyEmail = async (token) => {
return userId; return userId;
}; };
module.exports = { register, login, refresh, logout, verifyEmail, changePassword, confirmPasswordChange }; module.exports = {
register,
login,
refresh,
issueBiometricToken,
biometricLogin,
logout,
revokeBiometricToken,
verifyEmail,
changePassword,
confirmPasswordChange,
getUnlockAccountRequest,
validatePasswordResetBySignature,
};
+66 -6
View File
@@ -17,10 +17,17 @@ const isStrongPassword = (value, helpers) => {
return value; return value;
}; };
const registerSchema = Joi.object({ const passwordRuleCheckers = [
email: Joi.string().email().required(), { test: (value) => value.length >= 8, label: 'al menos 8 caracteres' },
password: Joi.string().max(100).required().custom(isStrongPassword), { test: (value) => /[A-Z]/.test(value), label: 'una mayúscula' },
}); { test: (value) => /[a-z]/.test(value), label: 'una minúscula' },
{ test: (value) => /\d/.test(value), label: 'un número' },
{ test: (value) => /[^A-Za-z0-9]/.test(value), label: 'un carácter especial' },
];
const getPasswordIssues = (value = '') => passwordRuleCheckers
.filter((rule) => !rule.test(value))
.map((rule) => rule.label);
const loginSchema = Joi.object({ const loginSchema = Joi.object({
email: Joi.string().email().required(), email: Joi.string().email().required(),
@@ -28,7 +35,11 @@ const loginSchema = Joi.object({
}); });
const refreshSchema = Joi.object({ const refreshSchema = Joi.object({
refreshToken: Joi.string().required(), refreshToken: Joi.string().optional(),
});
const biometricTokenSchema = Joi.object({
biometricToken: Joi.string().required(),
}); });
const changePasswordSchema = Joi.object({ const changePasswordSchema = Joi.object({
@@ -36,6 +47,47 @@ const changePasswordSchema = Joi.object({
newPassword: Joi.string().max(100).required().custom(isStrongPassword), newPassword: Joi.string().max(100).required().custom(isStrongPassword),
}); });
const unlockAccountSchema = Joi.object({
certPassword: Joi.string().required(),
newPassword: Joi.string().max(100).required().custom(isStrongPassword),
});
const validateRegisterMultipart = (req, res, next) => {
const { email, password, certPassword, consentAccepted } = req.body;
const issues = [];
if (!email || Joi.string().email().validate(email).error) {
issues.push('Correo electrónico inválido');
}
if (!password) {
issues.push('La contraseña es obligatoria');
} else {
const passwordIssues = getPasswordIssues(password);
if (passwordIssues.length > 0) {
issues.push(`La contraseña debe incluir ${passwordIssues.join(', ')}`);
}
}
if (!certPassword) {
issues.push('La contraseña del certificado es obligatoria para firmar los documentos legales');
}
if (!req.file) {
issues.push('Debes cargar un certificado P12 o PFX');
}
if (!['true', true, 'on', '1', 1].includes(consentAccepted)) {
issues.push('Debes aceptar los documentos legales para completar el registro');
}
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 validate = (schema) => (req, res, next) => {
const { error } = schema.validate(req.body, { abortEarly: false }); const { error } = schema.validate(req.body, { abortEarly: false });
if (error) { if (error) {
@@ -45,4 +97,12 @@ const validate = (schema) => (req, res, next) => {
next(); next();
}; };
module.exports = { validate, registerSchema, loginSchema, refreshSchema, changePasswordSchema }; module.exports = {
validate,
validateRegisterMultipart,
loginSchema,
refreshSchema,
biometricTokenSchema,
changePasswordSchema,
unlockAccountSchema,
};
@@ -41,6 +41,14 @@ const findById = async (id) => {
return rows[0] || null; return rows[0] || null;
}; };
const findLatestActiveByUser = async (userId) => {
const [rows] = await db.execute(
'SELECT * FROM certificates WHERE user_id = ? AND is_active = 1 ORDER BY created_at DESC, id DESC LIMIT 1',
[userId]
);
return rows[0] || null;
};
const deactivate = async (id) => { const deactivate = async (id) => {
await db.execute('UPDATE certificates SET is_active = 0 WHERE id = ?', [id]); await db.execute('UPDATE certificates SET is_active = 0 WHERE id = ?', [id]);
}; };
@@ -70,6 +78,7 @@ module.exports = {
create, create,
findByUser, findByUser,
findById, findById,
findLatestActiveByUser,
deactivate, deactivate,
remove, remove,
saveChainStatus, saveChainStatus,
@@ -126,6 +126,8 @@ const getOne = async (id, userId) => {
}; };
}; };
const getLatestActiveForUser = async (userId) => certificatesRepository.findLatestActiveByUser(userId);
const remove = async (id, userId, context = {}) => { const remove = async (id, userId, context = {}) => {
const cert = await certificatesRepository.findById(id); const cert = await certificatesRepository.findById(id);
if (!cert) throw makeError('Certificate not found.', 404); if (!cert) throw makeError('Certificate not found.', 404);
@@ -165,4 +167,12 @@ const loadForSigning = async (certId, userId, password) => {
return { privateKey, certificate }; return { privateKey, certificate };
}; };
module.exports = { upload, list, getOne, remove, loadForSigning }; 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,583 @@
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;
}
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 normalizedInvitees = invitees.map((invitee) => ({
name: invitee.name.trim(),
email: invitee.email.trim().toLowerCase(),
}));
const payloadEmails = new Set();
for (const invitee of normalizedInvitees) {
if (payloadEmails.has(invitee.email)) {
const error = new Error(`El correo ${invitee.email} está repetido en la solicitud.`);
error.statusCode = 409;
throw error;
}
payloadEmails.add(invitee.email);
}
const existingEmails = new Set(
flow.status === 'completed'
? []
: existingInvitees.map((item) => String(item.invitee_email).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,
};
@@ -0,0 +1,58 @@
const legalService = require('./legal.service');
const { sendSuccess } = require('../../utils/apiResponse');
const list = async (req, res, next) => {
try {
const items = await legalService.listByUser(req.user.id);
return sendSuccess(res, items, 200);
} catch (error) {
return next(error);
}
};
const getOne = async (req, res, next) => {
try {
const item = await legalService.getOne(req.params.id, req.user.id);
const { originalPath, signedPath, ...safe } = item;
return sendSuccess(res, safe, 200);
} catch (error) {
return next(error);
}
};
const download = async (req, res, next) => {
try {
const item = await legalService.getOne(req.params.id, req.user.id);
return res.download(item.signedPath, item.signedFilename);
} catch (error) {
return next(error);
}
};
const templates = async (req, res, next) => {
try {
const items = await legalService.getTemplates();
return sendSuccess(res, items, 200);
} catch (error) {
return next(error);
}
};
const templatePreview = async (req, res, next) => {
try {
const { filename, pdfBytes } = await legalService.getTemplatePreview(req.params.slug);
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `inline; filename="${filename}"`);
return res.send(Buffer.from(pdfBytes));
} catch (error) {
return next(error);
}
};
module.exports = {
list,
getOne,
download,
templates,
templatePreview,
};
@@ -0,0 +1,45 @@
const fs = require('fs/promises');
const path = require('path');
const env = require('../../config/env');
const metadataDir = path.join(env.storagePath, 'legal', 'metadata');
const metadataFile = path.join(metadataDir, 'legal-documents.json');
const readAll = async () => {
try {
const raw = await fs.readFile(metadataFile, 'utf8');
return JSON.parse(raw);
} catch {
return [];
}
};
const writeAll = async (items) => {
await fs.mkdir(metadataDir, { recursive: true });
await fs.writeFile(metadataFile, JSON.stringify(items, null, 2), 'utf8');
};
const createMany = async (items) => {
const current = await readAll();
current.push(...items);
await writeAll(current);
return items;
};
const findByUser = async (userId) => {
const current = await readAll();
return current
.filter((item) => item.userId === userId)
.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
};
const findById = async (id) => {
const current = await readAll();
return current.find((item) => item.id === id) || null;
};
module.exports = {
createMany,
findByUser,
findById,
};
+13
View File
@@ -0,0 +1,13 @@
const express = require('express');
const authenticate = require('../../middlewares/authenticate');
const legalController = require('./legal.controller');
const router = express.Router();
router.get('/templates', legalController.templates);
router.get('/templates/:slug/preview', legalController.templatePreview);
router.get('/', authenticate, legalController.list);
router.get('/:id', authenticate, legalController.getOne);
router.get('/:id/download', authenticate, legalController.download);
module.exports = router;
+225
View File
@@ -0,0 +1,225 @@
const fs = require('fs/promises');
const path = require('path');
const crypto = require('crypto');
const { PDFDocument, StandardFonts, rgb } = require('pdf-lib');
const legalRepository = require('./legal.repository');
const { getLegalTemplates, LEGAL_VERSION, EFFECTIVE_DATE } = require('./legal.templates');
const { getLegalDocumentPath } = require('../../utils/storagePath');
const { signPdf } = require('../signing/signing.service');
const auditService = require('../audit/audit.service');
const wrapText = (text, font, size, maxWidth) => {
const words = String(text).split(/\s+/);
const lines = [];
let current = '';
words.forEach((word) => {
const candidate = current ? `${current} ${word}` : word;
if (font.widthOfTextAtSize(candidate, size) <= maxWidth) {
current = candidate;
} else {
if (current) lines.push(current);
current = word;
}
});
if (current) lines.push(current);
return lines;
};
const buildLegalPdfBytes = async ({ userName, userEmail, template }) => {
const pdfDoc = await PDFDocument.create();
const page = pdfDoc.addPage([595.28, 841.89]);
const bold = await pdfDoc.embedFont(StandardFonts.HelveticaBold);
const regular = await pdfDoc.embedFont(StandardFonts.Helvetica);
const margin = 46;
const width = page.getWidth() - (margin * 2);
let y = page.getHeight() - 56;
page.drawText('LinkedSign', { x: margin, y, size: 18, font: bold, color: rgb(0.09, 0.40, 0.75) });
y -= 28;
page.drawText(template.title, { x: margin, y, size: 15, font: bold, color: rgb(0.08, 0.10, 0.18) });
y -= 18;
page.drawText(`Versión ${template.version} · Vigente desde ${template.effectiveDate}`, { x: margin, y, size: 10, font: regular, color: rgb(0.38, 0.44, 0.60) });
y -= 20;
page.drawText(`Titular: ${userName}`, { x: margin, y, size: 10, font: regular, color: rgb(0.08, 0.10, 0.18) });
y -= 14;
page.drawText(`Correo: ${userEmail}`, { x: margin, y, size: 10, font: regular, color: rgb(0.08, 0.10, 0.18) });
y -= 18;
const summaryLines = wrapText(template.summary, regular, 10.5, width);
summaryLines.forEach((line) => {
page.drawText(line, { x: margin, y, size: 10.5, font: regular, color: rgb(0.25, 0.29, 0.39) });
y -= 13;
});
y -= 10;
template.sections.forEach((section) => {
page.drawText(section.heading, { x: margin, y, size: 11.5, font: bold, color: rgb(0.08, 0.10, 0.18) });
y -= 15;
section.body.forEach((paragraph) => {
const lines = wrapText(paragraph, regular, 10, width - 12);
lines.forEach((line, index) => {
const text = index === 0 ? `• ${line}` : ` ${line}`;
page.drawText(text, { x: margin, y, size: 10, font: regular, color: rgb(0.22, 0.25, 0.34) });
y -= 12;
});
y -= 6;
});
y -= 4;
});
return pdfDoc.save();
};
const buildLegalPdf = async ({ userName, userEmail, template, outputPath }) => {
const pdfBytes = await buildLegalPdfBytes({ userName, userEmail, template });
await fs.writeFile(outputPath, pdfBytes);
return pdfBytes;
};
const sanitizeSlug = (value) => value.replace(/[^a-z0-9-]/gi, '_');
const issueInitialLegalPackage = async ({
userId,
userName,
userEmail,
signerName,
p12Buffer,
p12Password,
context = {},
}) => {
const originalDir = await getLegalDocumentPath(userId, 'original');
const signedDir = await getLegalDocumentPath(userId, 'signed');
const timestamp = new Date();
const stamp = [
timestamp.getFullYear(),
String(timestamp.getMonth() + 1).padStart(2, '0'),
String(timestamp.getDate()).padStart(2, '0'),
String(timestamp.getHours()).padStart(2, '0'),
String(timestamp.getMinutes()).padStart(2, '0'),
].join('');
const templates = getLegalTemplates();
const created = [];
for (const template of templates) {
const baseName = `${sanitizeSlug(template.slug)}_${stamp}`;
const originalFilename = `${baseName}.pdf`;
const originalPath = path.join(originalDir, originalFilename);
await buildLegalPdf({
userName,
userEmail,
template,
outputPath: originalPath,
});
const { signedFilename, signedPath } = await signPdf({
pdfPath: originalPath,
p12Buffer,
p12Password,
signerName,
reason: 'Aceptación legal de registro',
location: 'LinkedSign',
signatureStyle: 'qr',
qrPosition: 'right',
qrBackground: 'transparent',
qrBorder: 'none',
outputDir: signedDir,
outputBaseName: `${baseName}_signed`,
});
const signedBuffer = await fs.readFile(signedPath);
const hashSha256 = crypto.createHash('sha256').update(signedBuffer).digest('hex');
created.push({
id: crypto.randomUUID(),
userId,
slug: template.slug,
title: template.title,
version: template.version,
effectiveDate: template.effectiveDate,
createdAt: timestamp.toISOString(),
originalFilename,
originalPath,
signedFilename,
signedPath,
hashSha256,
});
}
await legalRepository.createMany(created);
await auditService.logEvent({
type: 'LEGAL_PACKAGE_SIGNED',
userId,
email: userEmail,
ip: context.ip,
userAgent: context.userAgent,
metadata: {
version: LEGAL_VERSION,
documents: created.map((item) => ({ slug: item.slug, title: item.title })),
},
});
return created;
};
const listByUser = async (userId) => {
const items = await legalRepository.findByUser(userId);
return items.map(({ originalPath, signedPath, ...safe }) => safe);
};
const getOne = async (id, userId) => {
const item = await legalRepository.findById(id);
if (!item) {
const error = new Error('Legal document not found.');
error.statusCode = 404;
throw error;
}
if (item.userId !== userId) {
const error = new Error('Access denied.');
error.statusCode = 403;
throw error;
}
return item;
};
const getTemplates = async () => getLegalTemplates().map((template) => ({
slug: template.slug,
title: template.title,
version: template.version,
effectiveDate: template.effectiveDate,
summary: template.summary,
sections: template.sections,
}));
const getTemplatePreview = async (slug) => {
const template = getLegalTemplates().find((item) => item.slug === slug);
if (!template) {
const error = new Error('Legal template not found.');
error.statusCode = 404;
throw error;
}
const pdfBytes = await buildLegalPdfBytes({
userName: 'Vista previa de registro',
userEmail: 'preview@linkedsign.local',
template,
});
return {
filename: `${sanitizeSlug(slug)}_preview.pdf`,
pdfBytes,
};
};
module.exports = {
issueInitialLegalPackage,
listByUser,
getOne,
getTemplates,
getTemplatePreview,
};
@@ -0,0 +1,170 @@
const env = require('../../config/env');
const LEGAL_VERSION = '1.1.0';
const EFFECTIVE_DATE = '2026-04-07';
const buildControllerBlock = () => {
const {
controllerName,
platformName,
contactEmail,
contactPhone,
contactAddress,
website,
rightsEmail,
} = env.legal;
return {
controllerName,
platformName,
contactEmail,
contactPhone,
contactAddress,
website,
rightsEmail,
};
};
const getLegalTemplates = () => {
const controller = buildControllerBlock();
return [
{
slug: 'terms-of-use',
title: `Términos de Uso de ${controller.platformName}`,
version: LEGAL_VERSION,
effectiveDate: EFFECTIVE_DATE,
summary: `Condiciones de acceso y uso de ${controller.platformName} como plataforma de gestión documental y firma electrónica.`,
sections: [
{
heading: 'Identificación del prestador',
body: [
`${controller.controllerName} opera ${controller.platformName} como plataforma tecnológica para gestión documental, firma electrónica, administración de certificados y conservación de evidencia técnica asociada.`,
`Canales de contacto del responsable: correo ${controller.contactEmail}, teléfono ${controller.contactPhone}, dirección ${controller.contactAddress} y sitio web ${controller.website}.`,
],
},
{
heading: 'Objeto y alcance',
body: [
`Estos términos regulan el acceso y uso de ${controller.platformName}. La persona usuaria declara que utilizará el sistema de forma lícita, veraz, diligente y conforme a la normativa ecuatoriana aplicable.`,
'El servicio está orientado a personas que cuentan con capacidad legal suficiente y, cuando corresponda, con un certificado digital válido y autorizado para su uso.',
],
},
{
heading: 'Responsabilidades de la persona usuaria',
body: [
'La persona usuaria es responsable del resguardo de sus credenciales, de la confidencialidad de su certificado digital y del contenido de los documentos que cargue o firme dentro del sistema.',
'La persona usuaria declara que el archivo P12 o PFX cargado le pertenece o que cuenta con autorización suficiente para usarlo y que su utilización no vulnera derechos de terceros.',
],
},
{
heading: 'Uso de la firma electrónica y de la plataforma',
body: [
`${controller.platformName} procesa documentos PDF, certificados digitales, metadatos de firma y evidencia técnica para habilitar firma electrónica visible y criptográfica, verificación y trazabilidad de operaciones.`,
'La contraseña del certificado proporcionada durante el registro inicial se utiliza exclusivamente para firmar los documentos legales de alta y no se conserva como credencial de acceso ni como dato persistente del sistema.',
],
},
{
heading: 'Disponibilidad, conservación y soporte',
body: [
`${controller.platformName} podrá conservar documentos firmados, evidencias técnicas, registros de auditoría, comprobantes de aceptación legal y demás soportes durante el tiempo necesario para prestar el servicio, atender reclamaciones y cumplir obligaciones legales aplicables.`,
`Las consultas, incidentes y solicitudes relacionadas con la cuenta, el servicio o los documentos legales pueden remitirse a ${controller.contactEmail}.`,
],
},
],
},
{
slug: 'privacy-policy',
title: 'Política de Privacidad y Aviso de Tratamiento de Datos',
version: LEGAL_VERSION,
effectiveDate: EFFECTIVE_DATE,
summary: `Información sobre tratamiento de datos personales conforme a la LOPDP para personas usuarias de ${controller.platformName}.`,
sections: [
{
heading: 'Responsable del tratamiento',
body: [
`El responsable del tratamiento es ${controller.controllerName}, operador de ${controller.platformName}.`,
`Datos de contacto del responsable: correo ${controller.contactEmail}, teléfono ${controller.contactPhone}, dirección ${controller.contactAddress} y sitio web ${controller.website}.`,
],
},
{
heading: 'Datos personales tratados',
body: [
'Se pueden tratar datos identificativos y de contacto, correo electrónico, credenciales de acceso cifradas, datos del certificado digital, dirección IP, agente de usuario, registros de auditoría, archivos cargados, documentos firmados, metadatos de firma y evidencia técnica vinculada al servicio.',
'Durante el alta legal también se trata el archivo del certificado digital y la contraseña del certificado exclusivamente para validar el certificado y firmar los documentos legales iniciales.',
],
},
{
heading: 'Finalidades y base jurídica',
body: [
`Los datos se tratan para crear y administrar la cuenta, autenticar a la persona usuaria, habilitar firma electrónica, administrar certificados, generar y conservar evidencia de operaciones, atender solicitudes, enviar notificaciones y cumplir obligaciones legales, regulatorias o contractuales.`,
'La base jurídica del tratamiento podrá ser la ejecución de la relación precontractual o contractual, el cumplimiento de obligaciones legales y, cuando corresponda, el consentimiento expreso, libre, específico, informado e inequívoco de la persona titular.',
],
},
{
heading: 'Conservación y consecuencias de la entrega de datos',
body: [
'Los datos se conservarán mientras la cuenta permanezca activa y, posteriormente, durante el tiempo necesario para cumplir obligaciones legales, atender reclamaciones, resguardar evidencias y cumplir políticas de conservación documental y de seguridad.',
`Si la persona titular no proporciona los datos mínimos requeridos o no entrega un certificado digital válido para el alta legal inicial, ${controller.platformName} no podrá completar el registro ni habilitar las funciones de firma electrónica que dependan de dicho certificado.`,
],
},
{
heading: 'Destinatarios, encargados y transferencias',
body: [
'Los datos podrán ser tratados por proveedores tecnológicos que actúen por cuenta del responsable para servicios de hosting, almacenamiento, correo transaccional, seguridad, soporte o respaldo, siempre bajo instrucciones documentadas y medidas de seguridad adecuadas.',
'No se realizan decisiones basadas únicamente en tratamientos automatizados con efectos jurídicos para la persona titular ni perfiles comerciales automatizados en el marco de este flujo de registro. Si existieran transferencias nacionales o internacionales necesarias para la prestación del servicio o por obligación legal, se realizarán con las salvaguardas exigidas por la normativa aplicable.',
],
},
{
heading: 'Derechos de la persona titular y canal de atención',
body: [
'La persona titular puede ejercer los derechos previstos en la LOPDP, incluyendo acceso, rectificación, actualización, eliminación, oposición, portabilidad, suspensión del tratamiento y revocatoria del consentimiento cuando sea jurídicamente procedente.',
`Las solicitudes para ejercicio de derechos, consultas o reclamos pueden enviarse a ${controller.rightsEmail}. ${controller.controllerName} atenderá dichas solicitudes conforme a los plazos y requisitos establecidos en la normativa aplicable.`,
],
},
],
},
{
slug: 'data-processing-consent',
title: 'Consentimiento Expreso para Tratamiento de Datos y Uso de Firma Electrónica',
version: LEGAL_VERSION,
effectiveDate: EFFECTIVE_DATE,
summary: 'Aceptación expresa del tratamiento de datos personales, del uso del certificado digital para el alta inicial y de la generación de evidencia legal del registro.',
sections: [
{
heading: 'Declaración de consentimiento',
body: [
`La persona usuaria declara de forma expresa, libre, específica, informada e inequívoca que ha leído y comprendido los Términos de Uso, la Política de Privacidad y este documento, y que acepta el tratamiento de sus datos personales por ${controller.controllerName} para operar dentro de ${controller.platformName}.`,
'La persona usuaria autoriza el uso del certificado digital cargado exclusivamente para firmar los documentos legales del alta inicial, generar evidencia de aceptación y habilitar el flujo de registro dentro de la plataforma.',
],
},
{
heading: 'Uso del certificado y de la contraseña del certificado',
body: [
'La contraseña del archivo P12 o PFX se utilizará únicamente durante el proceso técnico de validación del certificado y firma de los documentos legales iniciales.',
'La contraseña del certificado no será almacenada por la plataforma como credencial persistente ni será reutilizada automáticamente para otras operaciones posteriores.',
],
},
{
heading: 'Evidencia y entrega de copias',
body: [
'La aceptación queda respaldada por los documentos legales firmados electrónicamente, por el correo registrado, por la fecha y hora del registro, por la huella digital del archivo firmado y por los registros técnicos asociados a la operación.',
`La persona usuaria recibirá una copia de los documentos legales firmados en el correo registrado y podrá consultarlos posteriormente dentro de ${controller.platformName} en la sección Legal, en modo de solo lectura.`,
],
},
{
heading: 'Revocatoria y límites',
body: [
`La persona titular podrá solicitar información adicional o ejercer sus derechos mediante ${controller.rightsEmail}. La revocatoria del consentimiento, cuando resulte jurídicamente procedente, no afectará la licitud de los tratamientos realizados con anterioridad ni la conservación obligatoria de evidencias y soportes legales.`,
],
},
],
},
];
};
module.exports = {
LEGAL_VERSION,
EFFECTIVE_DATE,
getLegalTemplates,
};
+7 -2
View File
@@ -31,14 +31,19 @@ const getTransporter = () => {
return transporter; return transporter;
}; };
const sendMail = async ({ to, subject, html, text }) => { 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,
text, text,
attachments,
}); });
return info; return info;
+232 -43
View File
@@ -1,38 +1,82 @@
const env = require('../../config/env'); const env = require('../../config/env');
const mailRepository = require('./mail.repository'); const mailRepository = require('./mail.repository');
const sendVerificationEmail = async ({ name, email, verificationToken }) => { const escapeHtml = (value) => String(value ?? '')
const verificationUrl = `${env.frontendUrl.replace(/\/$/, '')}/auth/verify-email/${verificationToken}`; .replace(/&/g, '&amp;')
const subject = 'Confirma tu cuenta de LinkedSign'; .replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
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 verificationUrl = `${buildBaseUrl()}/auth/verify-email/${verificationToken}`;
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.' : '',
'',
'Si no solicitaste esta cuenta, puedes ignorar este mensaje.', 'Si no solicitaste esta cuenta, puedes ignorar este mensaje.',
].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>
<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,
subject, subject,
html, html,
text, text,
attachments,
}); });
if (!env.mail.user || !env.mail.pass) { if (!env.mail.user || !env.mail.pass) {
@@ -58,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>
`; `;
@@ -80,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},`,
@@ -91,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,
@@ -121,8 +165,153 @@ const sendPasswordChangeConfirmationEmail = async ({ name, email, confirmationTo
}; };
}; };
const sendAccountLockedEmail = async ({ name, email, unlockToken }) => {
const unlockUrl = `${buildBaseUrl()}/auth/unlock-account/${unlockToken}`;
const subject = 'Tu cuenta de LinkedSign fue bloqueada temporalmente';
const text = [
`Hola ${name},`,
'',
'Detectamos 3 intentos fallidos de acceso y tu cuenta fue bloqueada temporalmente por seguridad.',
'Para iniciar el desbloqueo, usa este enlace:',
unlockUrl,
'',
'El sistema te pedirá la contraseña de tu certificado de firma y luego solicitará una nueva contraseña de acceso.',
'Si no reconoces esta actividad, contacta soporte de inmediato.',
].join('\n');
const html = renderEmailLayout({
preheader: 'Tu cuenta fue bloqueada temporalmente por seguridad.',
title: 'Cuenta bloqueada temporalmente',
intro: `Hola ${escapeHtml(name)}. Detectamos varios intentos fallidos de acceso y bloqueamos tu cuenta de forma temporal por seguridad.`,
bodyHtml: '<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>',
ctaLabel: 'Iniciar desbloqueo',
ctaUrl: unlockUrl,
footerNote: 'Si no reconoces esta actividad, responde a este correo para revisión.',
});
const info = await mailRepository.sendMail({
to: email,
subject,
html,
text,
});
return {
unlockUrl,
messageId: info.messageId || null,
};
};
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,
sendExternalSigningInvitationEmail,
sendExternalSigningOwnerNotificationEmail,
}; };
@@ -8,18 +8,21 @@ const getCtx = (req) => ({
userName: req.user?.name || req.user?.email, userName: req.user?.name || req.user?.email,
userEmail: req.user?.email || null, userEmail: req.user?.email || null,
frontendUrl: req.headers.origin || null, frontendUrl: req.headers.origin || null,
clientLocale: req.body?.clientLocale || null,
clientTimeZone: req.body?.clientTimeZone || null,
clientCountry: req.body?.clientCountry || null,
}); });
const sign = async (req, res, next) => { const sign = async (req, res, next) => {
try { try {
const { documentId, certificateId, certPassword, reason, location, page, rect, signatureStyle, qrPosition, qrBackground, qrBorder, signatureImageBase64 } = req.body; const { documentId, certificateId, certPassword, reason, location, page, rect, signatureStyle, qrPosition, qrBackground, qrBorder, signatureImageBase64, clientLocale, clientTimeZone, clientCountry } = req.body;
const docId = Number(documentId); const docId = Number(documentId);
const certId = Number(certificateId); const certId = Number(certificateId);
if (!docId || !certId || isNaN(docId) || isNaN(certId)) { if (!docId || !certId || isNaN(docId) || isNaN(certId)) {
const e = new Error('documentId and certificateId are required.'); e.statusCode = 400; throw e; const e = new Error('documentId and certificateId are required.'); e.statusCode = 400; throw e;
} }
const result = await signaturesService.sign( const result = await signaturesService.sign(
{ documentId: docId, certificateId: certId, certPassword, reason, location, page, rect, signatureStyle, qrPosition, qrBackground, qrBorder, signatureImageBase64 }, { documentId: docId, certificateId: certId, certPassword, reason, location, page, rect, signatureStyle, qrPosition, qrBackground, qrBorder, signatureImageBase64, clientLocale, clientTimeZone, clientCountry },
req.user.id, req.user.id,
getCtx(req) getCtx(req)
); );
@@ -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,
@@ -5,11 +5,12 @@ const signaturesRepository = require('./signatures.repository');
const documentsRepository = require('../documents/documents.repository'); const documentsRepository = require('../documents/documents.repository');
const certificatesRepository = require('../certificates/certificates.repository'); const certificatesRepository = require('../certificates/certificates.repository');
const certificatesService = require('../certificates/certificates.service'); const certificatesService = require('../certificates/certificates.service');
const usersRepository = require('../users/users.repository');
const { signPdf } = require('../signing/signing.service'); const { signPdf } = require('../signing/signing.service');
const auditService = require('../audit/audit.service'); const auditService = require('../audit/audit.service');
const env = require('../../config/env'); const env = require('../../config/env');
const sign = async ({ documentId, certificateId, certPassword, reason, location, page, rect, signatureStyle, qrPosition, qrBackground, qrBorder, signatureImageBase64 }, userId, context = {}) => { const sign = async ({ documentId, certificateId, certPassword, reason, location, page, rect, signatureStyle, qrPosition, qrBackground, qrBorder, signatureImageBase64, clientLocale, clientTimeZone, clientCountry }, userId, context = {}) => {
const doc = await documentsRepository.findById(documentId); const doc = await documentsRepository.findById(documentId);
if (!doc) { const e = new Error('Document not found.'); e.statusCode = 404; throw e; } if (!doc) { const e = new Error('Document not found.'); e.statusCode = 404; throw e; }
if (doc.user_id !== userId) { const e = new Error('Access denied.'); e.statusCode = 403; throw e; } if (doc.user_id !== userId) { const e = new Error('Access denied.'); e.statusCode = 403; throw e; }
@@ -32,6 +33,12 @@ const sign = async ({ documentId, certificateId, certPassword, reason, location,
const verificationCode = uuidv4(); const verificationCode = uuidv4();
const frontendBaseUrl = (context.frontendUrl || env.frontendUrl || '').replace(/\/$/, ''); const frontendBaseUrl = (context.frontendUrl || env.frontendUrl || '').replace(/\/$/, '');
const verificationUrl = `${frontendBaseUrl}/verify/${verificationCode}`; const verificationUrl = `${frontendBaseUrl}/verify/${verificationCode}`;
const storedSignaturePreferences = await usersRepository.getSignaturePreferences(userId);
const effectiveSignatureStyle = storedSignaturePreferences.signatureStyle || signatureStyle || 'classic';
const effectiveQrPosition = storedSignaturePreferences.qrPosition || qrPosition || 'left';
const effectiveQrBackground = storedSignaturePreferences.qrBackground || qrBackground || 'transparent';
const effectiveQrBorder = storedSignaturePreferences.qrBorder || qrBorder || 'none';
const effectiveSignatureImageBase64 = storedSignaturePreferences.signatureImageBase64 || signatureImageBase64 || null;
let signedFilename, signedPath; let signedFilename, signedPath;
try { try {
@@ -44,12 +51,15 @@ const sign = async ({ documentId, certificateId, certPassword, reason, location,
location: location || '', location: location || '',
page: page || 0, page: page || 0,
rect: rect || null, rect: rect || null,
signatureStyle: signatureStyle || 'classic', signatureStyle: effectiveSignatureStyle,
qrPosition: qrPosition || 'left', qrPosition: effectiveQrPosition,
qrBackground: qrBackground || 'transparent', qrBackground: effectiveQrBackground,
qrBorder: qrBorder || 'none', qrBorder: effectiveQrBorder,
signatureImageBase64: signatureImageBase64 || null, signatureImageBase64: effectiveSignatureImageBase64,
verificationUrl, verificationUrl,
clientLocale: clientLocale || context.clientLocale || null,
clientTimeZone: clientTimeZone || context.clientTimeZone || null,
clientCountry: clientCountry || context.clientCountry || null,
})); }));
} catch (signErr) { } catch (signErr) {
const msg = signErr.message || ''; const msg = signErr.message || '';
+180 -11
View File
@@ -1,7 +1,7 @@
const fs = require('fs/promises'); const fs = require('fs/promises');
const path = require('path'); const path = require('path');
const QRCode = require('qrcode'); const QRCode = require('qrcode');
const { PDFDocument, rgb, StandardFonts } = require('pdf-lib'); const { PDFDocument, PDFArray, PDFName, PDFNumber, rgb, StandardFonts } = require('pdf-lib');
const { pdflibAddPlaceholder } = require('@signpdf/placeholder-pdf-lib'); const { pdflibAddPlaceholder } = require('@signpdf/placeholder-pdf-lib');
const { SignPdf } = require('@signpdf/signpdf'); const { SignPdf } = require('@signpdf/signpdf');
const { P12Signer } = require('@signpdf/signer-p12'); const { P12Signer } = require('@signpdf/signer-p12');
@@ -33,11 +33,104 @@ function truncate(text, font, size, maxWidth) {
} }
function getSignatureWidth(style, signerName = '') { function getSignatureWidth(style, signerName = '') {
if (style !== 'qr') return 200; if (style === 'qr' || style === 'firmaec') {
const normalizedName = String(signerName).trim(); const normalizedName = String(signerName).trim();
const calculatedWidth = 78 + (normalizedName.length * 4.9); const calculatedWidth = 78 + (normalizedName.length * 4.9);
return Math.max(170, Math.min(270, Math.round(calculatedWidth))); return Math.max(170, Math.min(270, Math.round(calculatedWidth)));
} }
return 200;
}
function wrapToLines(text, font, size, maxWidth, maxLines) {
const words = text.split(' ');
const lines = [];
let current = '';
for (const word of words) {
if (lines.length >= maxLines) break;
const test = current ? `${current} ${word}` : word;
if (font.widthOfTextAtSize(test, size) <= maxWidth) {
current = test;
} else {
if (current) lines.push(current);
current = truncate(word, font, size, maxWidth);
}
}
if (current && lines.length < maxLines) lines.push(current);
return lines;
}
function getFirmaEcNameLines(signerName, font, size, maxWidth) {
const words = String(signerName || '')
.trim()
.toUpperCase()
.split(/\s+/)
.filter(Boolean);
if (words.length >= 4) {
return [
words.slice(0, 2).join(' '),
words.slice(2).join(' '),
].map((line) => truncate(line, font, size, maxWidth));
}
return wrapToLines(words.join(' '), font, size, maxWidth, 2);
}
function buildLocalizedSignatureTimestamp({ locale, timeZone } = {}) {
const now = new Date();
const normalizedLocale = locale || 'es-EC';
const options = timeZone ? { timeZone } : {};
try {
return {
dateStr: now.toLocaleDateString(normalizedLocale, {
day: '2-digit',
month: '2-digit',
year: 'numeric',
...options,
}),
timeStr: now.toLocaleTimeString(normalizedLocale, {
hour: '2-digit',
minute: '2-digit',
hour12: true,
...options,
}),
};
} catch {
return {
dateStr: now.toLocaleDateString('es-EC', { day: '2-digit', month: '2-digit', year: 'numeric' }),
timeStr: now.toLocaleTimeString('es-EC', { hour: '2-digit', minute: '2-digit', hour12: true }),
};
}
}
function hideSignatureWidgetBorder(pdfDoc, pageIndex) {
const page = pdfDoc.getPages()[pageIndex];
if (!page) return;
const annotations = page.node.lookupMaybe(PDFName.of('Annots'), PDFArray);
if (!annotations || annotations.size() === 0) return;
const widgetRef = annotations.get(annotations.size() - 1);
const widget = pdfDoc.context.lookup(widgetRef);
if (!widget) return;
const zeroBorder = PDFArray.withContext(pdfDoc.context);
zeroBorder.push(PDFNumber.of(0));
zeroBorder.push(PDFNumber.of(0));
zeroBorder.push(PDFNumber.of(0));
widget.set(PDFName.of('Border'), zeroBorder);
widget.set(PDFName.of('BS'), pdfDoc.context.obj({
Type: 'Border',
W: 0,
S: 'S',
}));
widget.set(PDFName.of('MK'), pdfDoc.context.obj({
BC: [],
BG: [],
}));
}
// ── Style: CLASSIC (blue bar + DS badge) ────────────────────────────────────── // ── Style: CLASSIC (blue bar + DS badge) ──────────────────────────────────────
async function drawStyleClassic(pdfDoc, page, sx, sy, sw, sh, { signerName, reason, dateStr, timeStr, location }) { async function drawStyleClassic(pdfDoc, page, sx, sy, sw, sh, { signerName, reason, dateStr, timeStr, location }) {
@@ -212,6 +305,68 @@ async function drawStyleImage(pdfDoc, page, sx, sy, sw, sh, { signerName, reason
if (location) page.drawText(truncate(location, f2, 6, textW), { x: textX, y: sy + sh - 59, size: 6, font: f2, color: C.lightGray }); if (location) page.drawText(truncate(location, f2, 6, textW), { x: textX, y: sy + sh - 59, size: 6, font: f2, color: C.lightGray });
} }
// ── Style: FIRMAEC (QR izquierda + texto monoespaciado estilo FirmaEC Ecuador) ─
async function drawStyleFirmaEC(pdfDoc, page, sx, sy, sw, sh, { signerName, verificationUrl, qrPosition, qrBackground, qrBorder }) {
const fBold = await pdfDoc.embedFont(StandardFonts.CourierBold);
const fReg = await pdfDoc.embedFont(StandardFonts.Courier);
const useSoftBackground = qrBackground === 'soft';
const useBorder = qrBorder === 'line';
const qrOnRight = qrPosition === 'right';
const qrContent = verificationUrl || [
'FIRMA DIGITAL VERIFICADA',
`Firmante: ${signerName}`,
].join('\n');
const qrPng = await QRCode.toBuffer(qrContent, {
type: 'png',
width: 120,
margin: 1,
color: { dark: '#000000', light: '#ffffff' },
});
const qrImage = await pdfDoc.embedPng(qrPng);
const qrSize = Math.min(sh - 10, 50);
// Background
const bgColor = useSoftBackground ? C.bgBlue : C.white;
page.drawRectangle({ x: sx, y: sy, width: sw, height: sh, color: bgColor });
if (useBorder) {
page.drawRectangle({
x: sx,
y: sy,
width: sw,
height: sh,
borderColor: rgb(0.15, 0.15, 0.15),
borderWidth: 0.85,
});
}
// QR image
const qrX = qrOnRight ? sx + sw - qrSize - 4 : sx + 4;
const qrY = sy + (sh - qrSize) / 2;
page.drawImage(qrImage, { x: qrX, y: qrY, width: qrSize, height: qrSize });
// Vertical divider belongs to the bordered variant only.
const divX = qrOnRight ? qrX - 4 : sx + qrSize + 8;
if (useBorder) {
page.drawLine({ start: { x: divX, y: sy + 6 }, end: { x: divX, y: sy + sh - 6 }, thickness: 0.5, color: rgb(0.55, 0.55, 0.58) });
}
// Text column
const textX = qrOnRight ? sx + 6 : divX + 5;
const textW = sw - qrSize - 18;
page.drawText('Validar unicamente en FirmaEC.', { x: textX, y: sy + sh - 12, size: 6, font: fReg, color: rgb(0.12, 0.12, 0.14) });
page.drawText('Firmado electronicamente por:', { x: textX, y: sy + sh - 21, size: 6, font: fReg, color: rgb(0.12, 0.12, 0.14) });
// Name — bold, uppercase, word-wrapped up to 2 lines
const nameSize = 8.5;
const nameLines = getFirmaEcNameLines(signerName, fBold, nameSize, textW);
nameLines.forEach((line, i) => {
page.drawText(line, { x: textX, y: sy + sh - 34 - (i * 13), size: nameSize, font: fBold, color: rgb(0.05, 0.05, 0.07) });
});
}
// ── Main sign function ───────────────────────────────────────────────────────── // ── Main sign function ─────────────────────────────────────────────────────────
const signPdf = async ({ const signPdf = async ({
pdfPath, p12Buffer, p12Password, pdfPath, p12Buffer, p12Password,
@@ -222,6 +377,10 @@ const signPdf = async ({
qrBackground = 'transparent', qrBackground = 'transparent',
qrBorder = 'none', qrBorder = 'none',
verificationUrl = null, verificationUrl = null,
clientLocale = null,
clientTimeZone = null,
outputDir = null,
outputBaseName = null,
}) => { }) => {
const pdfBytes = await fs.readFile(pdfPath); const pdfBytes = await fs.readFile(pdfPath);
const pdfDoc = await PDFDocument.load(pdfBytes, { ignoreEncryption: true }); const pdfDoc = await PDFDocument.load(pdfBytes, { ignoreEncryption: true });
@@ -256,9 +415,10 @@ const signPdf = async ({
}; };
const { x: sx, y: sy, width: sw, height: sh } = sigRect; const { x: sx, y: sy, width: sw, height: sh } = sigRect;
const now = new Date(); const { dateStr, timeStr } = buildLocalizedSignatureTimestamp({
const dateStr = now.toLocaleDateString('es-EC', { day: '2-digit', month: '2-digit', year: 'numeric' }); locale: clientLocale,
const timeStr = now.toLocaleTimeString('es-EC', { hour: '2-digit', minute: '2-digit', hour12: true }); timeZone: clientTimeZone,
});
const info = { signerName, reason, dateStr, timeStr, location, signatureImageBase64, verificationUrl, qrPosition, qrBackground, qrBorder }; const info = { signerName, reason, dateStr, timeStr, location, signatureImageBase64, verificationUrl, qrPosition, qrBackground, qrBorder };
@@ -267,6 +427,7 @@ const signPdf = async ({
if (style === 'qr') await drawStyleQR(pdfDoc, tp, sx, sy, sw, sh, info); if (style === 'qr') await drawStyleQR(pdfDoc, tp, sx, sy, sw, sh, info);
else if (style === 'professional') await drawStyleProfessional(pdfDoc, tp, sx, sy, sw, sh, info); else if (style === 'professional') await drawStyleProfessional(pdfDoc, tp, sx, sy, sw, sh, info);
else if (style === 'image') await drawStyleImage(pdfDoc, tp, sx, sy, sw, sh, info); else if (style === 'image') await drawStyleImage(pdfDoc, tp, sx, sy, sw, sh, info);
else if (style === 'firmaec') await drawStyleFirmaEC(pdfDoc, tp, sx, sy, sw, sh, info);
else await drawStyleClassic(pdfDoc, tp, sx, sy, sw, sh, info); else await drawStyleClassic(pdfDoc, tp, sx, sy, sw, sh, info);
// ── Save page with visual stamp ─────────────────────────────────── // ── Save page with visual stamp ───────────────────────────────────
@@ -274,16 +435,24 @@ const signPdf = async ({
// ── Add cryptographic signature placeholder ─────────────────────── // ── Add cryptographic signature placeholder ───────────────────────
const pdfDocForSign = await PDFDocument.load(pdfWithStamp); const pdfDocForSign = await PDFDocument.load(pdfWithStamp);
const placeholderPageIndex = Math.min(page, pdfDocForSign.getPageCount() - 1);
const placeholderPage = pdfDocForSign.getPages()[placeholderPageIndex];
const useInvisibleWidget = style === 'firmaec' && qrBorder === 'none';
const widgetRect = useInvisibleWidget
? [0, 0, 0, 0]
: [sx, sy, sx + sw, sy + sh];
pdflibAddPlaceholder({ pdflibAddPlaceholder({
pdfDoc: pdfDocForSign, pdfPage: placeholderPage,
reason: reason || 'Firma digital', reason: reason || 'Firma digital',
contactInfo: signerName, contactInfo: signerName,
name: signerName, name: signerName,
location: location || '', location: location || '',
signatureLength: 32768, signatureLength: 32768,
rect: [sx, sy, sx + sw, sy + sh], widgetRect,
pageIndex: Math.min(page, pdfDocForSign.getPageCount() - 1),
}); });
if (!useInvisibleWidget) {
hideSignatureWidgetBorder(pdfDocForSign, placeholderPageIndex);
}
const pdfWithPlaceholder = Buffer.from(await pdfDocForSign.save({ useObjectStreams: false })); const pdfWithPlaceholder = Buffer.from(await pdfDocForSign.save({ useObjectStreams: false }));
@@ -293,9 +462,9 @@ const signPdf = async ({
const signedBuf = await signInstance.sign(pdfWithPlaceholder, signer); const signedBuf = await signInstance.sign(pdfWithPlaceholder, signer);
// ── Save ────────────────────────────────────────────────────────── // ── Save ──────────────────────────────────────────────────────────
const destDir = await getSignedDocumentPath(); const destDir = outputDir || await getSignedDocumentPath();
const ext = path.extname(pdfPath); const ext = path.extname(pdfPath);
const base = path.basename(pdfPath, ext).replace(/^\d+-/, ''); const base = outputBaseName || path.basename(pdfPath, ext).replace(/^\d+-/, '');
const nowForFilename = new Date(); const nowForFilename = new Date();
const year = String(nowForFilename.getFullYear()); const year = String(nowForFilename.getFullYear());
const month = String(nowForFilename.getMonth() + 1).padStart(2, '0'); const month = String(nowForFilename.getMonth() + 1).padStart(2, '0');
@@ -303,7 +472,7 @@ const signPdf = async ({
const hours = String(nowForFilename.getHours()).padStart(2, '0'); const hours = String(nowForFilename.getHours()).padStart(2, '0');
const minutes = String(nowForFilename.getMinutes()).padStart(2, '0'); const minutes = String(nowForFilename.getMinutes()).padStart(2, '0');
const dateSuffix = `${year}${month}${day}${hours}${minutes}`; const dateSuffix = `${year}${month}${day}${hours}${minutes}`;
const signedFilename = `${base}_${dateSuffix}${ext}`; const signedFilename = outputBaseName ? `${base}${ext}` : `${base}_${dateSuffix}${ext}`;
const signedPath = path.join(destDir, signedFilename); const signedPath = path.join(destDir, signedFilename);
await fs.writeFile(signedPath, signedBuf); await fs.writeFile(signedPath, signedBuf);
+18 -26
View File
@@ -1,44 +1,36 @@
const usersRepository = require('./users.repository'); const usersRepository = require('./users.repository');
const ALLOWED_SIGNATURE_STYLES = ['classic', 'qr', 'professional', 'image']; const ALLOWED_SIGNATURE_STYLES = ['classic', 'qr', 'professional', 'firmaec'];
const ALLOWED_QR_POSITIONS = ['left', 'right']; const ALLOWED_QR_POSITIONS = ['left', 'right'];
const ALLOWED_QR_BACKGROUNDS = ['transparent', 'soft']; const ALLOWED_QR_BACKGROUNDS = ['transparent', 'soft'];
const ALLOWED_QR_BORDERS = ['none', 'line']; const ALLOWED_QR_BORDERS = ['none', 'line'];
const LEGACY_SIGNATURE_STYLE_MAP = {
image: 'qr',
firmaecuador: 'firmaec',
'firma ec': 'firmaec',
};
const getSignaturePreferences = async (userId) => { const getSignaturePreferences = async (userId) => {
return usersRepository.getSignaturePreferences(userId); return usersRepository.getSignaturePreferences(userId);
}; };
const updateSignaturePreferences = async (userId, { signatureStyle, qrPosition, qrBackground, qrBorder, signatureImageBase64 }) => { const updateSignaturePreferences = async (userId, { signatureStyle, qrPosition, qrBackground, qrBorder, signatureImageBase64 }) => {
if (!ALLOWED_SIGNATURE_STYLES.includes(signatureStyle)) { const rawSignatureStyle = String(signatureStyle || '').trim().toLowerCase();
const error = new Error('Invalid signature style.'); let normalizedSignatureStyle = LEGACY_SIGNATURE_STYLE_MAP[rawSignatureStyle] || rawSignatureStyle || 'qr';
error.statusCode = 400; if (!ALLOWED_SIGNATURE_STYLES.includes(normalizedSignatureStyle)) {
throw error; normalizedSignatureStyle = rawSignatureStyle.includes('firma') ? 'firmaec' : 'qr';
} }
if (!ALLOWED_QR_POSITIONS.includes(qrPosition)) { // Preferences should be resilient to stale clients or partially populated forms.
const error = new Error('Invalid QR position.'); const resolvedQrPosition = ALLOWED_QR_POSITIONS.includes(qrPosition) ? qrPosition : 'left';
error.statusCode = 400; const resolvedQrBackground = ALLOWED_QR_BACKGROUNDS.includes(qrBackground) ? qrBackground : 'transparent';
throw error; const resolvedQrBorder = ALLOWED_QR_BORDERS.includes(qrBorder) ? qrBorder : 'none';
}
if (!ALLOWED_QR_BACKGROUNDS.includes(qrBackground)) {
const error = new Error('Invalid QR background.');
error.statusCode = 400;
throw error;
}
if (!ALLOWED_QR_BORDERS.includes(qrBorder)) {
const error = new Error('Invalid QR border.');
error.statusCode = 400;
throw error;
}
return usersRepository.setSignaturePreferences(userId, { return usersRepository.setSignaturePreferences(userId, {
signatureStyle, signatureStyle: normalizedSignatureStyle,
qrPosition, qrPosition: resolvedQrPosition,
qrBackground, qrBackground: resolvedQrBackground,
qrBorder, qrBorder: resolvedQrBorder,
signatureImageBase64: signatureImageBase64 || null, signatureImageBase64: signatureImageBase64 || null,
}); });
}; };
+6
View File
@@ -1,18 +1,24 @@
const express = require('express'); const express = require('express');
const authRoutes = require('../modules/auth/auth.routes'); const authRoutes = require('../modules/auth/auth.routes');
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 usersRoutes = require('../modules/users/users.routes'); const usersRoutes = require('../modules/users/users.routes');
const healthRoutes = require('../modules/health/health.routes'); const healthRoutes = require('../modules/health/health.routes');
const router = express.Router(); const router = express.Router();
router.use('/auth', authRoutes); router.use('/auth', authRoutes);
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('/signatures', signaturesRoutes); router.use('/signatures', signaturesRoutes);
router.use('/users', usersRoutes); router.use('/users', usersRoutes);
router.use('/health', healthRoutes); router.use('/health', healthRoutes);
+12
View File
@@ -1,6 +1,18 @@
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 invitationsRepository = require('./modules/invitations/invitations.repository');
const start = async () => {
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}`);
}); });
};
start().catch((error) => {
console.error('Failed to start backend:', error.message);
process.exit(1);
});
+19 -1
View File
@@ -26,4 +26,22 @@ const getCertificatePath = async (userId) => {
return fullPath; return fullPath;
}; };
module.exports = { getDocumentPath, getSignedDocumentPath, getCertificatePath }; const getTempUploadPath = async (bucket = 'misc') => {
const fullPath = path.join(env.storagePath, 'tmp', bucket);
await fs.mkdir(fullPath, { recursive: true });
return fullPath;
};
const getLegalDocumentPath = async (userId, variant = 'signed') => {
const fullPath = path.join(env.storagePath, 'legal', 'users', String(userId), variant);
await fs.mkdir(fullPath, { recursive: true });
return fullPath;
};
module.exports = {
getDocumentPath,
getSignedDocumentPath,
getCertificatePath,
getTempUploadPath,
getLegalDocumentPath,
};
+2 -2
View File
@@ -2,9 +2,9 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/png" href="/app-icon-96.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title> <title>LinkedSign</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "frontend", "name": "frontend",
"private": true, "private": true,
"version": "1.0.1", "version": "1.2.35",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

+14
View File
@@ -7,12 +7,16 @@ import LoginPage from './pages/auth/LoginPage';
import RegisterPage from './pages/auth/RegisterPage'; import RegisterPage from './pages/auth/RegisterPage';
import VerifyEmailPage from './pages/auth/VerifyEmailPage'; import VerifyEmailPage from './pages/auth/VerifyEmailPage';
import ConfirmPasswordChangePage from './pages/auth/ConfirmPasswordChangePage'; import ConfirmPasswordChangePage from './pages/auth/ConfirmPasswordChangePage';
import UnlockAccountPage from './pages/auth/UnlockAccountPage';
import DashboardPage from './pages/dashboard/DashboardPage'; import DashboardPage from './pages/dashboard/DashboardPage';
import DocumentsPage from './pages/documents/DocumentsPage'; import DocumentsPage from './pages/documents/DocumentsPage';
import CertificatesPage from './pages/certificates/CertificatesPage'; import CertificatesPage from './pages/certificates/CertificatesPage';
import SignatureStylePage from './pages/signature-style/SignatureStylePage'; import SignatureStylePage from './pages/signature-style/SignatureStylePage';
import SignaturesPage from './pages/signatures/SignaturesPage'; 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 AuditPage from './pages/audit/AuditPage';
import GuestInvitationPage from './pages/invitations/GuestInvitationPage';
export default function App() { export default function App() {
return ( return (
@@ -24,6 +28,8 @@ export default function App() {
<Route path="/register" element={<RegisterPage />} /> <Route path="/register" element={<RegisterPage />} />
<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="/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
@@ -46,6 +52,14 @@ export default function App() {
path="/signatures" path="/signatures"
element={<PrivateRoute><Layout><SignaturesPage /></Layout></PrivateRoute>} element={<PrivateRoute><Layout><SignaturesPage /></Layout></PrivateRoute>}
/> />
<Route
path="/legal"
element={<PrivateRoute><Layout><LegalPage /></Layout></PrivateRoute>}
/>
<Route
path="/audit"
element={<PrivateRoute><Layout><AuditPage /></Layout></PrivateRoute>}
/>
<Route path="*" element={<Navigate to="/dashboard" replace />} /> <Route path="*" element={<Navigate to="/dashboard" replace />} />
</Routes> </Routes>
</AuthProvider> </AuthProvider>
+10
View File
@@ -0,0 +1,10 @@
import client from './client';
export const listMyAuditEvents = ({ page = 1, limit = 15, type } = {}) => {
const params = { page, limit };
if (type) params.type = type;
return client.get('/audit/mine', { params });
};
export const purgeMyAuditEvents = (certPassword) =>
client.post('/audit/mine/purge', { certPassword });
+9 -2
View File
@@ -1,9 +1,16 @@
import client from './client'; import client from './client';
export const register = (data) => client.post('/auth/register', data); export const register = (data) => client.post('/auth/register', data, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
export const login = (data) => client.post('/auth/login', data); export const login = (data) => client.post('/auth/login', data);
export const logout = (refreshToken) => client.post('/auth/logout', { refreshToken }); export const refreshSession = () => client.post('/auth/refresh', {});
export const logout = () => client.post('/auth/logout', {});
export const getMe = () => client.get('/auth/me'); export const getMe = () => client.get('/auth/me');
export const verifyEmail = (token) => client.get(`/auth/verify/${token}`); export const verifyEmail = (token) => client.get(`/auth/verify/${token}`);
export const changePassword = (data) => client.patch('/auth/change-password', data); export const changePassword = (data) => client.patch('/auth/change-password', data);
export const confirmPasswordChange = (token) => client.get(`/auth/confirm-password-change/${token}`); export const confirmPasswordChange = (token) => client.get(`/auth/confirm-password-change/${token}`);
export const getUnlockAccountRequest = (token) => client.get(`/auth/unlock-account/${token}`);
export const requestUnlockPasswordChange = (token, data) => client.post(`/auth/unlock-account/${token}/request-password-change`, data);
+25 -11
View File
@@ -1,14 +1,22 @@
import axios from 'axios'; import axios from 'axios';
import {
clearAccessToken,
expireSession,
getAccessToken,
setAccessToken,
} from '../utils/session';
import { resolveApiUrl } from '../config/api';
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:4000'; const API_URL = resolveApiUrl();
const client = axios.create({ const client = axios.create({
baseURL: API_URL, baseURL: API_URL,
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
withCredentials: true,
}); });
client.interceptors.request.use((config) => { client.interceptors.request.use((config) => {
const token = localStorage.getItem('accessToken'); const token = getAccessToken();
if (token) config.headers.Authorization = `Bearer ${token}`; if (token) config.headers.Authorization = `Bearer ${token}`;
return config; return config;
}); });
@@ -17,24 +25,30 @@ client.interceptors.response.use(
(res) => res, (res) => res,
async (error) => { async (error) => {
const original = error.config; const original = error.config;
if (!original) {
return Promise.reject(error);
}
if (error.response?.status === 401 && !original._retry) { if (error.response?.status === 401 && !original._retry) {
original._retry = true; original._retry = true;
const refreshToken = localStorage.getItem('refreshToken'); const hadAccessToken = Boolean(getAccessToken());
if (refreshToken) { if (hadAccessToken) {
try { try {
const { data } = await axios.post(`${API_URL}/auth/refresh`, { refreshToken }); const { data } = await axios.post(`${API_URL}/auth/refresh`, {}, { withCredentials: true });
const { accessToken, refreshToken: newRefresh } = data.data; const { accessToken } = data.data;
localStorage.setItem('accessToken', accessToken); setAccessToken(accessToken);
localStorage.setItem('refreshToken', newRefresh);
original.headers.Authorization = `Bearer ${accessToken}`; original.headers.Authorization = `Bearer ${accessToken}`;
return client(original); return client(original);
} catch { } catch {
localStorage.removeItem('accessToken'); expireSession();
localStorage.removeItem('refreshToken');
window.location.href = '/login';
} }
} }
} }
if (error.response?.status === 401) {
clearAccessToken();
}
return Promise.reject(error); return Promise.reject(error);
} }
); );
+30
View File
@@ -0,0 +1,30 @@
import client from './client';
export const createInvitationFlow = (payload) => client.post('/invitations', payload);
export const getInvitationFlow = (documentId) => client.get(`/invitations/document/${documentId}`);
export const addInvitationFlowInvitees = (documentId, payload) => client.post(`/invitations/document/${documentId}/invitees`, payload);
export const deleteInvitation = (invitationId) => client.delete(`/invitations/${invitationId}`);
export const getPublicInvitation = (token) => client.get(`/invitations/public/${token}`);
export const acceptPublicInvitationTerms = (token) => client.post(`/invitations/public/${token}/accept-terms`);
export const downloadPublicInvitationDocument = (token) => client.get(`/invitations/public/${token}/document`, {
responseType: 'blob',
});
export const signPublicInvitation = (token, payload) => {
const form = new FormData();
form.append('certificateFile', payload.certificateFile);
form.append('certPassword', payload.certPassword);
form.append('page', String(payload.page));
form.append('rect', JSON.stringify(payload.rect));
form.append('acceptedTerms', payload.acceptedTerms ? 'true' : 'false');
if (payload.reason) form.append('reason', payload.reason);
if (payload.location) form.append('location', payload.location);
return client.post(`/invitations/public/${token}/sign`, form, {
headers: { 'Content-Type': 'multipart/form-data' },
});
};
+7
View File
@@ -0,0 +1,7 @@
import client from './client';
export const listLegalDocuments = () => client.get('/legal');
export const downloadLegalDocument = (id) => client.get(`/legal/${id}/download`, {
responseType: 'blob',
});
+5 -1
View File
@@ -1,5 +1,9 @@
import axios from 'axios'; import axios from 'axios';
import { resolveApiUrl } from '../config/api';
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:4000'; const API_URL = resolveApiUrl();
export const verifySignature = (code) => axios.get(`${API_URL}/signatures/verify/${code}`); export const verifySignature = (code) => axios.get(`${API_URL}/signatures/verify/${code}`);
export const getLegalTemplates = () => axios.get(`${API_URL}/legal/templates`);
export const getLegalTemplatePreview = (slug) =>
axios.get(`${API_URL}/legal/templates/${slug}/preview`, { responseType: 'blob' });
+21 -17
View File
@@ -1,16 +1,17 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Link, useNavigate, useLocation } from 'react-router-dom'; import { Link, useNavigate, useLocation } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext'; import { useAuth } from '../../contexts/AuthContext';
import { FileText, Shield, PenTool, LayoutDashboard, LogOut, Palette, Menu, X, KeyRound, Search, Plus, Users, Settings, ScrollText } from 'lucide-react'; import { FileText, Shield, PenTool, LayoutDashboard, LogOut, Palette, Menu, X, KeyRound, Users, Settings, ScrollText, Scale } from 'lucide-react';
import ChangePasswordModal from '../ui/ChangePasswordModal'; import ChangePasswordModal from '../ui/ChangePasswordModal';
import packageInfo from '../../../package.json';
const navSections = [ const navSections = [
{ {
label: 'Principal', label: 'Principal',
items: [ items: [
{ to: '/dashboard', icon: LayoutDashboard, label: 'Dashboard' }, { to: '/dashboard', icon: LayoutDashboard, label: 'Dashboard' },
{ to: '/documents', icon: FileText, label: 'Mis documentos' },
{ to: '/signatures', icon: PenTool, label: 'Firmar ahora' }, { to: '/signatures', icon: PenTool, label: 'Firmar ahora' },
{ to: '/documents', icon: FileText, label: 'Mis documentos' },
{ to: '/certificates', icon: Users, label: 'Firmantes' }, { to: '/certificates', icon: Users, label: 'Firmantes' },
], ],
}, },
@@ -18,6 +19,7 @@ const navSections = [
label: 'Sistema', label: 'Sistema',
items: [ items: [
{ to: '/signature-style', icon: Settings, label: 'Configuración' }, { to: '/signature-style', icon: Settings, label: 'Configuración' },
{ to: '/audit', icon: ScrollText, label: 'Auditoría' },
], ],
}, },
]; ];
@@ -28,6 +30,8 @@ const topbarTitles = {
'/certificates': 'Firmantes', '/certificates': 'Firmantes',
'/signature-style': 'Configuración', '/signature-style': 'Configuración',
'/signatures': 'Firmar ahora', '/signatures': 'Firmar ahora',
'/legal': 'Legal',
'/audit': 'Auditoría',
}; };
export default function Layout({ children }) { export default function Layout({ children }) {
@@ -80,11 +84,11 @@ export default function Layout({ children }) {
<aside className={`sidebar ${mobileMenuOpen ? 'sidebar--mobile-open' : ''}`}> <aside className={`sidebar ${mobileMenuOpen ? 'sidebar--mobile-open' : ''}`}>
<div className="sidebar-brand"> <div className="sidebar-brand">
<div className="sidebar-brand-mark"> <div className="sidebar-brand-mark">
<FileText size={18} /> <img src="/app-icon-96.png" alt="LinkedSign" width="32" height="32" style={{ display: 'block' }} />
</div> </div>
<div className="sidebar-brand-copy"> <div className="sidebar-brand-copy">
<span>FirmaDoc</span> <span>LinkedSign <em>v{packageInfo.version}</em></span>
<small>Plataforma web</small> <small>Firma Electrónica @LinkedCorp</small>
</div> </div>
<button <button
type="button" type="button"
@@ -112,12 +116,16 @@ export default function Layout({ children }) {
))} ))}
</div> </div>
))} ))}
<div className="sidebar-group sidebar-group--disabled"> <div className="sidebar-group">
<div className="nav-group-label">Extra</div> <div className="nav-group-label">Extra</div>
<span className="nav-item nav-item--static"> <Link
<ScrollText size={15} /> to="/legal"
<span>Auditoría</span> className={`nav-item ${location.pathname.startsWith('/legal') ? 'active' : ''}`}
</span> onClick={() => setMobileMenuOpen(false)}
>
<Scale size={15} />
<span>Legal</span>
</Link>
</div> </div>
</nav> </nav>
<div className="sidebar-footer"> <div className="sidebar-footer">
@@ -144,14 +152,10 @@ export default function Layout({ children }) {
<main className="main-content"> <main className="main-content">
<div className="topbar"> <div className="topbar">
<div className="topbar-title">{currentTitle}</div> <div className="topbar-title">{currentTitle}</div>
<div className="search-box"> <div className="topbar-brand-logo" aria-hidden="true">
<Search size={13} /> <span className="topbar-brand-by">by</span>
<input placeholder="Buscar documentos..." aria-label="Buscar documentos" /> <img src="/logoLinkedCorpTxtBlack.png" alt="LinkedCorp" />
</div> </div>
<button className="btn btn-primary topbar-cta" type="button" onClick={() => navigate('/documents')}>
<Plus size={14} />
Nuevo documento
</button>
</div> </div>
{children} {children}
</main> </main>
@@ -1,7 +1,8 @@
import { useState, useRef, useCallback } from 'react'; import { useEffect, useRef, useState } from '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';
import { detectClientSignatureContext } from '../../utils/clientSignatureContext';
pdfjs.GlobalWorkerOptions.workerSrc = new URL( pdfjs.GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/build/pdf.worker.min.mjs', 'pdfjs-dist/build/pdf.worker.min.mjs',
@@ -12,12 +13,20 @@ const SIG_W = 200;
const SIG_H = 60; const SIG_H = 60;
const getSignatureWidth = (style, signerName = '') => { const getSignatureWidth = (style, signerName = '') => {
if (style !== 'qr') return SIG_W; if (style !== 'qr' && style !== 'firmaec') return SIG_W;
const normalizedName = signerName.trim(); const normalizedName = signerName.trim();
const calculatedWidth = 78 + (normalizedName.length * 4.9); const calculatedWidth = 78 + (normalizedName.length * 4.9);
return Math.max(170, Math.min(270, Math.round(calculatedWidth))); return Math.max(170, Math.min(270, Math.round(calculatedWidth)));
}; };
const getFirmaEcPreviewLines = (signerName = '') => {
const words = String(signerName).trim().toUpperCase().split(/\s+/).filter(Boolean);
if (words.length >= 4) {
return [words.slice(0, 2).join(' '), words.slice(2).join(' ')];
}
return [words.join(' ')];
};
export default function PdfSignaturePlacer({ export default function PdfSignaturePlacer({
pdfBlob, pdfBlob,
signerName, signerName,
@@ -28,13 +37,37 @@ export default function PdfSignaturePlacer({
signatureImage = null, signatureImage = null,
onPlaced, onPlaced,
}) { }) {
const clientSignatureContext = detectClientSignatureContext();
const [numPages, setNumPages] = useState(null); const [numPages, setNumPages] = useState(null);
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const [sigBox, setSigBox] = useState(null); // { x, y } in container px const [sigBox, setSigBox] = useState(null); // { x, y } in container px
const stageRef = useRef(null);
const containerRef = useRef(null); const containerRef = useRef(null);
const [pageSize, setPageSize] = useState(null); // rendered px size const [pageSize, setPageSize] = useState(null); // rendered px size
const [pageRenderWidth, setPageRenderWidth] = useState(600);
const signatureWidth = getSignatureWidth(signatureStyle, signerName); const signatureWidth = getSignatureWidth(signatureStyle, signerName);
useEffect(() => {
const stage = stageRef.current;
if (!stage) return undefined;
const updateWidth = () => {
const nextWidth = Math.max(280, Math.min(600, Math.floor(stage.clientWidth || 600)));
setPageRenderWidth(nextWidth);
};
updateWidth();
if (typeof ResizeObserver === 'undefined') {
window.addEventListener('resize', updateWidth);
return () => window.removeEventListener('resize', updateWidth);
}
const observer = new ResizeObserver(() => updateWidth());
observer.observe(stage);
return () => observer.disconnect();
}, []);
const getCanvasMetrics = () => { const getCanvasMetrics = () => {
const container = containerRef.current; const container = containerRef.current;
const canvas = container?.querySelector('.react-pdf__Page__canvas'); const canvas = container?.querySelector('.react-pdf__Page__canvas');
@@ -59,12 +92,12 @@ export default function PdfSignaturePlacer({
setPageSize({ width: page.width, height: page.height }); setPageSize({ width: page.width, height: page.height });
}; };
const handleContainerClick = (e) => { const handlePointerDown = (event) => {
const metrics = getCanvasMetrics(); const metrics = getCanvasMetrics();
if (!metrics) return; if (!metrics) return;
const x = e.clientX - metrics.canvasRect.left; const x = event.clientX - metrics.canvasRect.left;
const y = e.clientY - metrics.canvasRect.top; const y = event.clientY - metrics.canvasRect.top;
const clampedX = Math.min(Math.max(x, 0), metrics.width - signatureWidth); const clampedX = Math.min(Math.max(x, 0), metrics.width - signatureWidth);
const clampedY = Math.min(Math.max(y, 0), metrics.height - SIG_H); const clampedY = Math.min(Math.max(y, 0), metrics.height - SIG_H);
@@ -97,7 +130,9 @@ export default function PdfSignaturePlacer({
}); });
}; };
const previewDate = new Date().toLocaleDateString('es-EC'); const previewDate = new Date().toLocaleDateString(clientSignatureContext.locale, {
timeZone: clientSignatureContext.timeZone,
});
const renderPreview = () => { const renderPreview = () => {
if (signatureStyle === 'qr') { if (signatureStyle === 'qr') {
@@ -135,6 +170,39 @@ export default function PdfSignaturePlacer({
); );
} }
if (signatureStyle === 'firmaec') {
const nameLines = getFirmaEcPreviewLines(signerName);
return (
<div className="sig-preview-body sig-preview-body--firmaec">
{qrPosition === 'left' && (
<>
<div className="sig-preview-qr-box sig-preview-qr-box--firmaec" data-background={qrBackground}>
<div className="sig-preview-qr-pattern sig-preview-qr-pattern--firmaec" />
</div>
{qrBorder === 'line' && <div className="sig-preview-divider" />}
</>
)}
<div className="sig-preview-text sig-preview-text--firmaec">
<small>Validar unicamente en FirmaEC.</small>
<small>Firmado electronicamente por:</small>
<strong>
{nameLines.map((line, index) => (
<span key={`${line}-${index}`}>{line}</span>
))}
</strong>
</div>
{qrPosition === 'right' && (
<>
{qrBorder === 'line' && <div className="sig-preview-divider" />}
<div className="sig-preview-qr-box sig-preview-qr-box--firmaec" data-background={qrBackground}>
<div className="sig-preview-qr-pattern sig-preview-qr-pattern--firmaec" />
</div>
</>
)}
</div>
);
}
if (signatureStyle === 'image') { if (signatureStyle === 'image') {
return ( return (
<div className="sig-preview-body sig-preview-body--image"> <div className="sig-preview-body sig-preview-body--image">
@@ -177,16 +245,17 @@ export default function PdfSignaturePlacer({
</div> </div>
</div> </div>
<div ref={stageRef} className="pdf-placer-stage">
<div <div
ref={containerRef} ref={containerRef}
className="pdf-placer-canvas" className="pdf-placer-canvas"
onClick={handleContainerClick} onPointerDown={handlePointerDown}
style={{ cursor: 'crosshair', position: 'relative', display: 'inline-block' }} style={{ cursor: 'crosshair', position: 'relative', display: 'inline-block' }}
> >
<Document file={pdfBlob} onLoadSuccess={handleDocLoad}> <Document file={pdfBlob} onLoadSuccess={handleDocLoad}>
<Page <Page
pageNumber={currentPage} pageNumber={currentPage}
width={600} width={pageRenderWidth}
onLoadSuccess={(p) => setPageSize({ onLoadSuccess={(p) => setPageSize({
width: p.width, height: p.height, width: p.width, height: p.height,
pdfWidth: p.originalWidth, pdfHeight: p.originalHeight, pdfWidth: p.originalWidth, pdfHeight: p.originalHeight,
@@ -197,16 +266,17 @@ export default function PdfSignaturePlacer({
{sigBox && ( {sigBox && (
<div <div
className={`sig-preview-box sig-preview-box--${signatureStyle}`} className={`sig-preview-box sig-preview-box--${signatureStyle}`}
data-background={signatureStyle === 'qr' ? qrBackground : undefined} data-background={signatureStyle === 'qr' || signatureStyle === 'firmaec' ? qrBackground : undefined}
data-border={signatureStyle === 'qr' ? qrBorder : undefined} data-border={signatureStyle === 'qr' || signatureStyle === 'firmaec' ? qrBorder : undefined}
style={{ left: sigBox.x, top: sigBox.y, width: signatureWidth, height: SIG_H }} style={{ left: sigBox.x, top: sigBox.y, width: signatureWidth, height: SIG_H }}
onClick={(e) => e.stopPropagation()} onPointerDown={(e) => e.stopPropagation()}
> >
{signatureStyle === 'classic' && <div className="sig-preview-bar" />} {signatureStyle === 'classic' && <div className="sig-preview-bar" />}
{renderPreview()} {renderPreview()}
</div> </div>
)} )}
</div> </div>
</div>
{sigBox && ( {sigBox && (
<div className="pdf-placer-actions"> <div className="pdf-placer-actions">
@@ -1,5 +1,4 @@
import { useRef } from 'react'; import { QrCode, Briefcase, Stamp, PanelsLeftBottom, PanelsRightBottom, CircleOff, Square, ShieldCheck } from 'lucide-react';
import { QrCode, Briefcase, ImageIcon, Stamp, PanelsLeftBottom, PanelsRightBottom, CircleOff, Square } from 'lucide-react';
const getQrPreviewWidth = (name = '') => { const getQrPreviewWidth = (name = '') => {
const extraWidth = Math.max(0, name.trim().length - 18) * 4; const extraWidth = Math.max(0, name.trim().length - 18) * 4;
@@ -55,7 +54,7 @@ const STYLES = [
</svg> </svg>
<span className="sp-qr-hint">Escanear</span> <span className="sp-qr-hint">Escanear</span>
</div> </div>
<div className="sp-divider-v" /> {qrBorder === 'line' && <div className="sp-divider-v" />}
</> </>
)} )}
<div className="sp-info sp-info--qr"> <div className="sp-info sp-info--qr">
@@ -65,7 +64,7 @@ const STYLES = [
</div> </div>
{qrPosition === 'right' && ( {qrPosition === 'right' && (
<> <>
<div className="sp-divider-v" /> {qrBorder === 'line' && <div className="sp-divider-v" />}
<div className="sp-qr-box" data-background={qrBackground} data-border={qrBorder}> <div className="sp-qr-box" data-background={qrBackground} data-border={qrBorder}>
<svg viewBox="0 0 10 10" className="sp-qr-svg" xmlns="http://www.w3.org/2000/svg"> <svg viewBox="0 0 10 10" className="sp-qr-svg" xmlns="http://www.w3.org/2000/svg">
{[0,1,2,3,4,5,6,7,8,9].map(r => {[0,1,2,3,4,5,6,7,8,9].map(r =>
@@ -85,6 +84,61 @@ const STYLES = [
</div> </div>
), ),
}, },
{
id: 'firmaec',
label: 'FirmaEC',
icon: ShieldCheck,
desc: 'Formato oficial FirmaEC: QR de validación + nombre en negrita',
preview: ({ name, qrPosition = 'left', qrBackground = 'transparent', qrBorder = 'none' }) => (
<div
className="sig-preview sig-preview--firmaec"
data-background={qrBackground}
data-border={qrBorder}
>
{qrPosition === 'left' && (
<>
<div className="sp-qr-box sp-qr-box--firmaec" data-background={qrBackground}>
<svg viewBox="0 0 10 10" className="sp-qr-svg" xmlns="http://www.w3.org/2000/svg">
{[0,1,2,3,4,5,6,7,8,9].map(r =>
[0,1,2,3,4,5,6,7,8,9].map(c => {
const v = ((r * 3 + c * 7 + r ^ c) % 3 === 0);
return v ? <rect key={`${r}-${c}`} x={c} y={r} width={1} height={1} fill="#000" /> : null;
})
)}
<rect x={0} y={0} width={3} height={3} fill="none" stroke="#000" strokeWidth={0.3} />
<rect x={7} y={0} width={3} height={3} fill="none" stroke="#000" strokeWidth={0.3} />
<rect x={0} y={7} width={3} height={3} fill="none" stroke="#000" strokeWidth={0.3} />
</svg>
</div>
{qrBorder === 'line' && <div className="sp-divider-v" />}
</>
)}
<div className="sp-firmaec-text">
<span>Validar únicamente en FirmaEC.</span>
<span>Firmado electrónicamente por:</span>
<strong>{name.toUpperCase()}</strong>
</div>
{qrPosition === 'right' && (
<>
{qrBorder === 'line' && <div className="sp-divider-v" />}
<div className="sp-qr-box sp-qr-box--firmaec" data-background={qrBackground}>
<svg viewBox="0 0 10 10" className="sp-qr-svg" xmlns="http://www.w3.org/2000/svg">
{[0,1,2,3,4,5,6,7,8,9].map(r =>
[0,1,2,3,4,5,6,7,8,9].map(c => {
const v = ((r * 3 + c * 7 + r ^ c) % 3 === 0);
return v ? <rect key={`${r}-${c}`} x={c} y={r} width={1} height={1} fill="#000" /> : null;
})
)}
<rect x={0} y={0} width={3} height={3} fill="none" stroke="#000" strokeWidth={0.3} />
<rect x={7} y={0} width={3} height={3} fill="none" stroke="#000" strokeWidth={0.3} />
<rect x={0} y={7} width={3} height={3} fill="none" stroke="#000" strokeWidth={0.3} />
</svg>
</div>
</>
)}
</div>
),
},
{ {
id: 'professional', id: 'professional',
label: 'Profesional', label: 'Profesional',
@@ -102,36 +156,11 @@ const STYLES = [
</div> </div>
), ),
}, },
{
id: 'image',
label: 'Imagen',
icon: ImageIcon,
desc: 'Sube tu firma manuscrita o imagen personalizada',
preview: ({ name, date, image }) => (
<div className="sig-preview sig-preview--image">
<div className="sp-img-bar" />
<div className="sp-img-area">
{image
? <img src={image} alt="firma" className="sp-sig-img" />
: <span className="sp-img-placeholder">Tu firma aquí</span>
}
</div>
<div className="sp-divider-v" />
<div className="sp-info">
<strong>{name}</strong>
<span>Firmado digitalmente</span>
<span>{date}</span>
</div>
</div>
),
},
]; ];
export default function SignatureStylePicker({ export default function SignatureStylePicker({
value, value,
onChange, onChange,
signatureImage,
onImageChange,
signerName, signerName,
qrPosition = 'left', qrPosition = 'left',
onQrPositionChange, onQrPositionChange,
@@ -140,19 +169,8 @@ export default function SignatureStylePicker({
qrBorder = 'none', qrBorder = 'none',
onQrBorderChange, onQrBorderChange,
}) { }) {
const fileRef = useRef();
const today = new Date().toLocaleDateString('es-EC'); const today = new Date().toLocaleDateString('es-EC');
const handleImageFile = (e) => {
const file = e.target.files[0];
if (!file) return;
if (!file.type.startsWith('image/')) return;
const reader = new FileReader();
reader.onload = (ev) => onImageChange(ev.target.result);
reader.readAsDataURL(file);
e.target.value = '';
};
return ( return (
<div className="style-picker"> <div className="style-picker">
<label className="field-label-standalone">Estilo de firma visual</label> <label className="field-label-standalone">Estilo de firma visual</label>
@@ -176,14 +194,13 @@ export default function SignatureStylePicker({
{s.preview({ {s.preview({
name: signerName || 'Milton González H.', name: signerName || 'Milton González H.',
date: today, date: today,
image: signatureImage,
qrPosition, qrPosition,
qrBackground, qrBackground,
qrBorder, qrBorder,
})} })}
</div> </div>
<p className="style-card-desc">{s.desc}</p> <p className="style-card-desc">{s.desc}</p>
{s.id === 'qr' && isActive && ( {(s.id === 'qr' || s.id === 'firmaec') && isActive && (
<div className="style-card-inline-tools"> <div className="style-card-inline-tools">
<div className="style-inline-group"> <div className="style-inline-group">
<button <button
@@ -267,21 +284,6 @@ export default function SignatureStylePicker({
); );
})} })}
</div> </div>
{value === 'image' && (
<div className="image-upload-area">
<button type="button" className="btn btn-ghost btn-sm" onClick={() => fileRef.current.click()}>
<ImageIcon size={14} /> {signatureImage ? 'Cambiar imagen' : 'Subir firma (PNG/JPG)'}
</button>
{signatureImage && (
<button type="button" className="btn btn-ghost btn-sm danger" onClick={() => onImageChange(null)}>
Quitar imagen
</button>
)}
<small className="field-hint">Fondo transparente (PNG) recomendado para mejor resultado</small>
<input ref={fileRef} type="file" accept="image/png,image/jpeg,image/webp" style={{ display: 'none' }} onChange={handleImageFile} />
</div>
)}
</div> </div>
); );
} }
+17
View File
@@ -0,0 +1,17 @@
const normalize = (value) => String(value || '').replace(/\/$/, '');
export const resolveApiUrl = () => {
if (typeof window !== 'undefined' && window.location?.hostname === 'sign.linkedcorp.net') {
return '/api';
}
return normalize(import.meta.env.VITE_API_URL || 'http://192.168.222.40:4000');
};
export const resolveAndroidApkUrl = () => {
if (typeof window !== 'undefined' && window.location?.hostname === 'sign.linkedcorp.net') {
return '/api/downloads/android-apk';
}
return `${resolveApiUrl()}/downloads/android-apk`;
};
+67 -12
View File
@@ -1,21 +1,32 @@
import { createContext, useContext, useState, useEffect, useCallback } from 'react'; import { createContext, useContext, useState, useEffect, useCallback, useRef } from 'react';
import { login as apiLogin, logout as apiLogout, getMe } from '../api/auth'; import { login as apiLogin, logout as apiLogout, getMe, refreshSession as apiRefreshSession } from '../api/auth';
import { updateSignatureAppearancePreference as apiUpdateSignatureAppearancePreference } from '../api/users'; import { updateSignatureAppearancePreference as apiUpdateSignatureAppearancePreference } from '../api/users';
import {
clearAccessToken,
expireSession,
getAccessToken,
SESSION_EXPIRED_EVENT,
setAccessToken,
} from '../utils/session';
const AuthContext = createContext(null); const AuthContext = createContext(null);
const IDLE_SESSION_MS = 15 * 60 * 1000;
const ACTIVE_REFRESH_MS = 9 * 60 * 1000;
const ACTIVITY_EVENTS = ['click', 'keydown', 'mousemove', 'scroll', 'touchstart'];
export function AuthProvider({ children }) { export function AuthProvider({ children }) {
const [user, setUser] = useState(null); const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const lastActivityRef = useRef(Date.now());
const lastRefreshRef = useRef(0);
useEffect(() => { useEffect(() => {
const token = localStorage.getItem('accessToken'); const token = getAccessToken();
if (token) { if (token) {
getMe() getMe()
.then(({ data }) => setUser(data.data.user)) .then(({ data }) => setUser(data.data.user))
.catch(() => { .catch(() => {
localStorage.removeItem('accessToken'); clearAccessToken();
localStorage.removeItem('refreshToken');
}) })
.finally(() => setLoading(false)); .finally(() => setLoading(false));
} else { } else {
@@ -23,20 +34,64 @@ export function AuthProvider({ children }) {
} }
}, []); }, []);
useEffect(() => {
const handleSessionExpired = () => setUser(null);
window.addEventListener(SESSION_EXPIRED_EVENT, handleSessionExpired);
return () => window.removeEventListener(SESSION_EXPIRED_EVENT, handleSessionExpired);
}, []);
useEffect(() => {
if (!user) return undefined;
const registerActivity = () => {
const now = Date.now();
if (now - lastActivityRef.current > IDLE_SESSION_MS) {
expireSession();
return;
}
lastActivityRef.current = now;
};
const keepAlive = async () => {
const now = Date.now();
if (now - lastActivityRef.current > IDLE_SESSION_MS) {
expireSession();
return;
}
if (now - lastRefreshRef.current < ACTIVE_REFRESH_MS || !getAccessToken()) return;
try {
const { data } = await apiRefreshSession();
setAccessToken(data.data.accessToken);
lastRefreshRef.current = now;
} catch {
expireSession();
}
};
ACTIVITY_EVENTS.forEach((eventName) => window.addEventListener(eventName, registerActivity, { passive: true }));
const intervalId = window.setInterval(keepAlive, 60 * 1000);
return () => {
ACTIVITY_EVENTS.forEach((eventName) => window.removeEventListener(eventName, registerActivity));
window.clearInterval(intervalId);
};
}, [user]);
const login = useCallback(async (email, password) => { const login = useCallback(async (email, password) => {
const { data } = await apiLogin({ email, password }); const { data } = await apiLogin({ email, password });
const { accessToken, refreshToken, user: userData } = data.data; const { accessToken, user: userData } = data.data;
localStorage.setItem('accessToken', accessToken); setAccessToken(accessToken);
localStorage.setItem('refreshToken', refreshToken); lastActivityRef.current = Date.now();
lastRefreshRef.current = Date.now();
setUser(userData); setUser(userData);
return userData; return userData;
}, []); }, []);
const logout = useCallback(async () => { const logout = useCallback(async () => {
const refreshToken = localStorage.getItem('refreshToken'); try { await apiLogout(); } catch { /* ignore */ }
try { await apiLogout(refreshToken); } catch { /* ignore */ } clearAccessToken();
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
setUser(null); setUser(null);
}, []); }, []);
+658 -26
View File
@@ -111,14 +111,19 @@ select {
width: 32px; width: 32px;
height: 32px; height: 32px;
border-radius: 8px; border-radius: 8px;
background: var(--c-primary);
color: #fff;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
flex-shrink: 0; flex-shrink: 0;
} }
.sidebar-brand-mark img {
width: 32px;
height: 32px;
border-radius: 8px;
object-fit: contain;
}
.sidebar-brand-copy { .sidebar-brand-copy {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -129,6 +134,16 @@ select {
font-size: 14px; font-size: 14px;
font-weight: 500; font-weight: 500;
color: var(--c-text); color: var(--c-text);
display: inline-flex;
align-items: baseline;
gap: 6px;
}
.sidebar-brand-copy span em {
font-size: 10px;
font-style: normal;
font-weight: 400;
color: var(--c-muted);
} }
.sidebar-brand-copy small { .sidebar-brand-copy small {
@@ -317,33 +332,29 @@ select {
color: var(--c-text); color: var(--c-text);
} }
.search-box { .topbar-brand-logo {
width: 220px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: flex-end;
gap: 6px; gap: 6px;
background: var(--c-bg); min-width: 0;
border: 0.5px solid var(--c-border); flex-shrink: 0;
border-radius: 8px; }
padding: 6px 10px;
.topbar-brand-by {
font-size: 11px;
line-height: 1;
font-weight: 500;
color: var(--c-muted); color: var(--c-muted);
text-transform: lowercase;
} }
.search-box input { .topbar-brand-logo img {
width: 100%; display: block;
border: none; height: 24px;
outline: none; width: auto;
background: transparent; max-width: 180px;
font-size: 12px; object-fit: contain;
color: var(--c-text);
}
.search-box input::placeholder {
color: var(--c-muted);
}
.topbar-cta {
white-space: nowrap;
} }
/* ─── Page ───────────────────────────────────────────────────── */ /* ─── Page ───────────────────────────────────────────────────── */
@@ -391,6 +402,7 @@ select {
.auth-link { text-align: center; margin-top: 20px; font-size: 13px; color: var(--gray-500); } .auth-link { text-align: center; margin-top: 20px; font-size: 13px; color: var(--gray-500); }
.auth-link a { color: var(--primary); font-weight: 600; text-decoration: none; } .auth-link a { color: var(--primary); font-weight: 600; text-decoration: none; }
.auth-link a:hover { text-decoration: underline; } .auth-link a:hover { text-decoration: underline; }
.auth-version { text-align: center; margin-top: 10px; font-size: 12px; color: var(--gray-400); }
.auth-download-btn { .auth-download-btn {
justify-content: center; justify-content: center;
} }
@@ -431,6 +443,257 @@ select {
color: var(--gray-500); color: var(--gray-500);
} }
.legal-register-box {
border: 1px solid var(--gray-300);
border-radius: 12px;
padding: 14px;
background: #f8fbff;
display: grid;
gap: 12px;
}
.legal-register-header {
display: flex;
gap: 10px;
align-items: flex-start;
color: var(--c-text);
}
.legal-register-header p {
margin-top: 3px;
color: var(--c-muted);
font-size: 12px;
}
.legal-register-list {
display: grid;
gap: 8px;
}
.legal-template-row {
border: 1px solid var(--gray-300);
border-radius: 10px;
padding: 12px 14px;
background: #fff;
}
.legal-template-stack {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 6px;
}
.legal-template-button {
border: none;
border-radius: 0;
padding: 0;
background: transparent;
color: var(--c-text);
display: flex;
align-items: flex-start;
gap: 8px;
cursor: pointer;
text-align: left;
}
.legal-template-button svg {
flex: 0 0 15px;
width: 15px;
height: 15px;
margin-top: 2px;
}
.legal-template-button:hover {
color: var(--primary);
}
.legal-template-button span {
font-weight: 600;
text-decoration: underline;
text-underline-offset: 2px;
}
.legal-template-version {
display: inline-flex;
align-items: center;
gap: 5px;
color: var(--c-muted);
font-weight: 500;
font-size: 12px;
}
.legal-template-version svg {
flex: 0 0 12px;
width: 12px;
height: 12px;
}
.legal-template-check {
display: flex;
align-items: center;
gap: 8px;
color: var(--c-text);
font-size: 12px;
white-space: nowrap;
}
.legal-template-check input {
accent-color: var(--primary);
flex: 0 0 15px;
width: 15px;
height: 15px;
opacity: 1;
}
.legal-template-check.is-disabled {
opacity: 1;
cursor: not-allowed;
}
.legal-template-check span {
color: var(--c-text);
}
.legal-template-check.is-disabled span {
color: var(--c-text);
}
.legal-register-status {
display: flex;
align-items: center;
gap: 8px;
color: var(--c-muted);
font-size: 12px;
}
.btn[disabled] {
opacity: 0.55;
cursor: not-allowed;
}
.legal-register-status.is-complete {
color: var(--success);
}
.legal-document-modal {
background: #fff;
border-radius: 12px;
width: min(840px, 95vw);
max-height: 92vh;
display: flex;
flex-direction: column;
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.35);
overflow: hidden;
}
.legal-document-body {
flex: 1;
overflow-y: auto;
display: flex;
justify-content: center;
padding: 16px;
background: var(--gray-100);
}
.legal-document-meta {
display: flex;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 14px;
font-size: 11px;
color: var(--gray-500);
}
.legal-document-summary {
margin-bottom: 16px;
color: var(--gray-700);
font-size: 13px;
line-height: 1.6;
}
.legal-document-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 16px;
border-top: 1px solid var(--gray-200);
background: var(--gray-50);
}
.audit-toolbar,
.audit-pagination {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
}
.audit-toolbar-group {
display: flex;
align-items: center;
gap: 8px;
color: var(--gray-700);
}
.audit-toolbar-group select {
border: 1px solid var(--gray-300);
border-radius: 8px;
background: #fff;
padding: 6px 10px;
}
.documents-page-size {
display: inline-flex;
align-items: center;
gap: 8px;
color: var(--gray-700);
font-size: 12px;
}
.documents-page-size select {
border: 1px solid var(--gray-300);
border-radius: 8px;
background: #fff;
padding: 6px 10px;
}
.audit-table-header,
.audit-table-row {
grid-template-columns: minmax(220px, 1.2fr) 180px 120px minmax(260px, 1.5fr);
}
.audit-event-cell {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.audit-event-icon {
width: 28px;
height: 28px;
border-radius: 8px;
background: var(--c-info-bg);
color: var(--c-primary);
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.audit-event-icon.is-error {
background: var(--c-danger-bg);
color: var(--c-danger);
}
.audit-detail-cell {
color: var(--gray-600);
line-height: 1.5;
}
/* ─── Buttons ────────────────────────────────────────────────── */ /* ─── Buttons ────────────────────────────────────────────────── */
.btn { .btn {
display: inline-flex; align-items: center; gap: 6px; display: inline-flex; align-items: center; gap: 6px;
@@ -698,6 +961,12 @@ select {
gap: 14px; gap: 14px;
} }
.legal-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 14px;
}
.cert-card { .cert-card {
background: #fff; background: #fff;
border: 1px solid var(--gray-200); border: 1px solid var(--gray-200);
@@ -736,6 +1005,33 @@ select {
.form-card h3 { font-size: 14px; font-weight: 500; margin-bottom: 16px; color: var(--c-text); } .form-card h3 { font-size: 14px; font-weight: 500; margin-bottom: 16px; color: var(--c-text); }
.form-card form { display: flex; flex-direction: column; gap: 14px; } .form-card form { display: flex; flex-direction: column; gap: 14px; }
.legal-summary-card {
display: flex;
align-items: flex-start;
gap: 12px;
margin-bottom: 16px;
}
.legal-summary-icon {
width: 40px;
height: 40px;
border-radius: 999px;
display: inline-flex;
align-items: center;
justify-content: center;
background: var(--c-info-bg);
color: var(--c-primary);
flex-shrink: 0;
}
.legal-card {
min-height: 100%;
}
.legal-footer {
gap: 8px;
}
.form-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 8px; } .form-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 8px; }
/* ─── Sign page ──────────────────────────────────────────────── */ /* ─── Sign page ──────────────────────────────────────────────── */
@@ -751,6 +1047,49 @@ select {
border: 0.5px solid var(--c-border); border: 0.5px solid var(--c-border);
border-radius: 12px; border-radius: 12px;
padding: 24px; padding: 24px;
display: flex;
flex-direction: column;
gap: 14px;
}
.select-action-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 10px;
align-items: stretch;
}
.select-action-buttons {
display: flex;
gap: 8px;
}
.btn-square {
min-width: 44px;
width: 44px;
padding-left: 0;
padding-right: 0;
justify-content: center;
}
.btn-inline-toggle {
width: fit-content;
display: inline-flex;
align-items: center;
gap: 8px;
}
.sign-certificate-field {
margin-bottom: 14px;
}
.sign-details-toggle {
margin-bottom: 16px;
}
.sign-form-card > .info-box + .sign-place-button,
.sign-place-button {
margin-top: 0;
} }
.sign-form-card form { display: flex; flex-direction: column; gap: 14px; } .sign-form-card form { display: flex; flex-direction: column; gap: 14px; }
@@ -1140,6 +1479,10 @@ select {
.pdf-placer-pages { display: flex; align-items: center; gap: 8px; } .pdf-placer-pages { display: flex; align-items: center; gap: 8px; }
.pdf-placer-pages .btn { color: #fff; border-color: rgba(255,255,255,.2); padding: 4px 10px; } .pdf-placer-pages .btn { color: #fff; border-color: rgba(255,255,255,.2); padding: 4px 10px; }
.pdf-placer-stage {
width: 100%;
}
.pdf-placer-canvas { .pdf-placer-canvas {
border: 2px dashed var(--primary); border: 2px dashed var(--primary);
border-radius: 0 0 8px 8px; border-radius: 0 0 8px 8px;
@@ -1171,6 +1514,18 @@ select {
border-color: transparent; border-color: transparent;
box-shadow: none; box-shadow: none;
} }
.sig-preview-box--firmaec {
background: transparent;
border-color: transparent;
box-shadow: none;
}
.sig-preview-box--firmaec[data-background='soft'] {
background: rgba(242, 244, 255, 0.96);
}
.sig-preview-box--firmaec[data-border='line'] {
border-color: rgba(55, 65, 81, 0.7);
box-shadow: 0 2px 8px rgba(31, 41, 55, 0.16);
}
.sig-preview-box--qr[data-background='soft'] { .sig-preview-box--qr[data-background='soft'] {
background: rgba(242, 244, 255, 0.96); background: rgba(242, 244, 255, 0.96);
} }
@@ -1535,6 +1890,88 @@ select {
flex-direction: column; flex-direction: column;
gap: 2px; gap: 2px;
} }
.sig-preview--firmaec {
display: flex;
align-items: stretch;
background: #fff;
border: 1px solid transparent;
padding: 8px;
gap: 8px;
}
.sig-preview--firmaec[data-background='soft'] {
background: #eef2ff;
}
.sig-preview--firmaec[data-border='line'] {
border-color: rgba(0, 0, 0, 0.45);
border-width: 1.5px;
}
.sp-qr-box--firmaec {
background: #fff;
}
.sp-qr-box--firmaec[data-background='soft'] {
background: #f5f5f5;
}
.sp-firmaec-text {
display: flex;
flex-direction: column;
justify-content: center;
gap: 4px;
flex: 1;
min-width: 0;
font-family: 'Courier New', Courier, monospace;
}
.sp-firmaec-text span {
font-size: 8.5px;
color: #1a1a1a;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sp-firmaec-text strong {
font-size: 11px;
font-weight: 800;
color: #0d0d0d;
white-space: normal;
line-height: 1.2;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
font-family: 'Courier New', Courier, monospace;
}
.sig-preview-body--firmaec {
display: flex;
align-items: center;
gap: 8px;
height: 100%;
}
.sig-preview-qr-box--firmaec {
background: #fff;
}
.sig-preview-qr-pattern--firmaec {
background:
linear-gradient(90deg, #111 20%, transparent 20% 40%, #111 40% 60%, transparent 60% 80%, #111 80%),
linear-gradient(#111 20%, transparent 20% 40%, #111 40% 60%, transparent 60% 80%, #111 80%);
opacity: 0.9;
}
.sig-preview-text--firmaec {
font-family: 'Courier New', Courier, monospace;
gap: 3px;
}
.sig-preview-text--firmaec strong {
text-transform: uppercase;
display: flex;
flex-direction: column;
line-height: 1.15;
}
.sig-preview-text--firmaec strong span {
display: block;
}
.sig-preview-divider {
width: 1px;
height: 70%;
background: rgba(85, 85, 88, 0.58);
}
.sig-preview--image { .sig-preview--image {
display: flex; display: flex;
align-items: stretch; align-items: stretch;
@@ -1669,6 +2106,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 {
@@ -1767,9 +2371,8 @@ select {
flex-basis: 100%; flex-basis: 100%;
} }
.search-box, .topbar-brand-logo {
.topbar-cta { display: none;
width: 100%;
} }
.page { .page {
@@ -1826,6 +2429,23 @@ select {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.select-action-row {
grid-template-columns: 1fr;
}
.select-action-buttons {
width: 100%;
}
.select-action-buttons .btn {
flex: 1;
}
.btn-inline-toggle {
width: 100%;
justify-content: center;
}
.steps-bar { .steps-bar {
flex-direction: column; flex-direction: column;
align-items: stretch; align-items: stretch;
@@ -1872,6 +2492,7 @@ select {
.pdf-placer-canvas { .pdf-placer-canvas {
overflow: auto; overflow: auto;
max-width: 100%;
} }
.pdf-placer-canvas .react-pdf__Page, .pdf-placer-canvas .react-pdf__Page,
@@ -1901,6 +2522,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;
+237
View File
@@ -0,0 +1,237 @@
import { useEffect, useState } from 'react';
import { AlertCircle, CheckCircle2, ChevronLeft, ChevronRight, FileSignature, LogIn, ShieldAlert, Trash2, UserPlus2, X } from 'lucide-react';
import toast from 'react-hot-toast';
import { listMyAuditEvents, purgeMyAuditEvents } from '../../api/audit';
import { useAuth } from '../../contexts/AuthContext';
const PAGE_SIZE_OPTIONS = [10, 15, 30, 50];
const EVENT_LABELS = {
USER_REGISTERED: 'Registro inicial',
LOGIN_SUCCESS: 'Acceso correcto',
LOGIN_FAILED: 'Error de acceso',
VERIFICATION_EMAIL_RESENT: 'Reenvío de validación',
PASSWORD_CHANGE_REQUESTED: 'Solicitud de cambio',
PASSWORD_CHANGED: 'Cambio correcto',
ACCOUNT_UNLOCK_PASSWORD_CHANGE_REQUESTED: 'Desbloqueo solicitado',
LEGAL_PACKAGE_SIGNED: 'Documentos legales firmados',
DOCUMENT_UPLOAD: 'Documento cargado',
CERTIFICATE_UPLOAD: 'Certificado cargado',
CERTIFICATE_DELETE: 'Certificado eliminado',
PROCESS_ERROR: 'Error de proceso',
};
const EVENT_ICONS = {
USER_REGISTERED: UserPlus2,
LOGIN_SUCCESS: LogIn,
LOGIN_FAILED: ShieldAlert,
PROCESS_ERROR: AlertCircle,
LEGAL_PACKAGE_SIGNED: FileSignature,
};
const formatDateTime = (value) => {
if (!value) return '—';
return new Date(value).toLocaleString('es-EC');
};
const renderMetadata = (metadata) => {
if (!metadata) return '—';
if (metadata.documents?.length) {
return metadata.documents.map((item) => item.title || item.slug).join(', ');
}
if (metadata.path || metadata.method || metadata.message) {
return [metadata.method, metadata.path, metadata.message].filter(Boolean).join(' · ');
}
if (metadata.alias || metadata.subject) {
return [metadata.alias, metadata.subject].filter(Boolean).join(' · ');
}
return Object.entries(metadata)
.slice(0, 3)
.map(([key, value]) => `${key}: ${typeof value === 'object' ? JSON.stringify(value) : value}`)
.join(' · ');
};
export default function AuditPage() {
const { user } = useAuth();
const [items, setItems] = useState([]);
const [loading, setLoading] = useState(true);
const [page, setPage] = useState(1);
const [limit, setLimit] = useState(15);
const [pagination, setPagination] = useState({ page: 1, totalPages: 1, total: 0 });
const [showPurgeModal, setShowPurgeModal] = useState(false);
const [certPassword, setCertPassword] = useState('');
const [purging, setPurging] = useState(false);
const loadEvents = () => {
setLoading(true);
return listMyAuditEvents({ page, limit })
.then(({ data }) => {
setItems(data.data.rows || []);
setPagination(data.data.pagination || { page: 1, totalPages: 1, total: 0 });
})
.catch(() => toast.error('No se pudieron cargar tus logs'))
.finally(() => setLoading(false));
};
useEffect(() => {
loadEvents();
}, [page, limit]);
const handlePurgeLogs = async (event) => {
event.preventDefault();
setPurging(true);
try {
const { data } = await purgeMyAuditEvents(certPassword);
toast.success(`Se eliminaron ${data.data.deletedCount} logs de tu cuenta.`);
setCertPassword('');
setShowPurgeModal(false);
setPage(1);
await loadEvents();
} catch (error) {
toast.error(error.response?.data?.error || 'No se pudieron eliminar tus logs.');
} finally {
setPurging(false);
}
};
return (
<div className="page">
{showPurgeModal && (
<div className="modal-overlay" onClick={() => setShowPurgeModal(false)}>
<div className="modal-card" onClick={(event) => event.stopPropagation()}>
<div className="modal-header">
<div className="modal-title">
<Trash2 size={18} />
<span>Eliminar mis logs</span>
</div>
<button type="button" className="btn-icon" onClick={() => setShowPurgeModal(false)}>
<X size={18} />
</button>
</div>
<p className="modal-desc">
Para eliminar tus logs debes validar la contraseña de tu firma registrada. Esta acción dejará un rastro mínimo visible solo para administración.
</p>
<form onSubmit={handlePurgeLogs} className="auth-form">
<div className="field">
<label>Contraseña de la firma</label>
<input
type="password"
value={certPassword}
onChange={(event) => setCertPassword(event.target.value)}
required
/>
</div>
<div className="modal-actions">
<button type="button" className="btn btn-secondary" onClick={() => setShowPurgeModal(false)}>
Cancelar
</button>
<button type="submit" className="btn btn-primary" disabled={purging}>
{purging ? 'Eliminando...' : 'Eliminar logs'}
</button>
</div>
</form>
</div>
</div>
)}
<div className="page-header">
<div>
<h2>Auditoría</h2>
<p>
{user?.role === 'admin'
? 'Aquí se conservan únicamente los rastros administrativos de purga de logs.'
: 'Tus eventos del sistema, del más nuevo al más antiguo, con IP, fecha y detalle técnico relevante.'}
</p>
</div>
</div>
<div className="form-card audit-toolbar">
<div className="audit-toolbar-group">
<strong>Total de eventos:</strong>
<span>{pagination.total}</span>
</div>
<div className="audit-toolbar-group">
<label htmlFor="audit-limit">Mostrar</label>
<select
id="audit-limit"
value={limit}
onChange={(event) => {
setPage(1);
setLimit(Number(event.target.value));
}}
>
{PAGE_SIZE_OPTIONS.map((option) => (
<option key={option} value={option}>{option}</option>
))}
</select>
</div>
{user?.role !== 'admin' && (
<button type="button" className="btn btn-secondary" onClick={() => setShowPurgeModal(true)}>
<Trash2 size={14} />
Eliminar mis logs
</button>
)}
</div>
<div className="panel">
<div className="table-header audit-table-header">
<span className="th">Evento</span>
<span className="th">Fecha</span>
<span className="th">IP</span>
<span className="th">Detalle</span>
</div>
{loading ? (
<div className="loading-area"><div className="spinner" /></div>
) : items.length === 0 ? (
<div className="empty-state">
<CheckCircle2 size={44} />
<p>Aún no hay eventos de auditoría para mostrar.</p>
</div>
) : (
items.map((item) => {
const Icon = EVENT_ICONS[item.type] || CheckCircle2;
return (
<div key={item.id} className="table-row audit-table-row">
<div className="audit-event-cell">
<div className={`audit-event-icon ${item.type === 'PROCESS_ERROR' || item.type === 'LOGIN_FAILED' ? 'is-error' : ''}`}>
<Icon size={14} />
</div>
<div>
<div className="doc-n">{EVENT_LABELS[item.type] || item.type}</div>
<div className="doc-m">{item.type}</div>
</div>
</div>
<span className="td">{formatDateTime(item.created_at)}</span>
<span className="td">{item.ip || '—'}</span>
<span className="td audit-detail-cell">{renderMetadata(item.metadata)}</span>
</div>
);
})
)}
</div>
<div className="form-card audit-pagination">
<button
type="button"
className="btn btn-secondary"
disabled={page <= 1}
onClick={() => setPage((current) => Math.max(1, current - 1))}
>
<ChevronLeft size={14} />
Anterior
</button>
<span>Página {pagination.page} de {pagination.totalPages}</span>
<button
type="button"
className="btn btn-secondary"
disabled={page >= pagination.totalPages}
onClick={() => setPage((current) => Math.min(pagination.totalPages, current + 1))}
>
Siguiente
<ChevronRight size={14} />
</button>
</div>
</div>
);
}
+25 -5
View File
@@ -1,16 +1,29 @@
import { useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { useNavigate, Link } from 'react-router-dom'; import { useNavigate, Link, useSearchParams } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext'; import { useAuth } from '../../contexts/AuthContext';
import { Download, PenTool } from 'lucide-react'; import { Download, PenTool } from 'lucide-react';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { SESSION_EXPIRED_MESSAGE, translateApiError } from '../../utils/session';
import { resolveAndroidApkUrl } from '../../config/api';
export default function LoginPage() { export default function LoginPage() {
const { login } = useAuth(); const { login } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const [form, setForm] = useState({ email: '', password: '' }); const [form, setForm] = useState({ email: '', password: '' });
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const apiBaseUrl = (import.meta.env.VITE_API_URL || 'http://localhost:4000').replace(/\/$/, ''); const androidApkUrl = resolveAndroidApkUrl();
const androidApkUrl = `${apiBaseUrl}/downloads/android-apk`; const showAndroidDownload = useMemo(() => {
if (typeof navigator === 'undefined') return false;
return /Android/i.test(navigator.userAgent || '');
}, []);
useEffect(() => {
if (searchParams.get('sessionExpired') === '1') {
toast.error(SESSION_EXPIRED_MESSAGE);
setSearchParams({}, { replace: true });
}
}, [searchParams, setSearchParams]);
const handleChange = (e) => setForm((f) => ({ ...f, [e.target.name]: e.target.value })); const handleChange = (e) => setForm((f) => ({ ...f, [e.target.name]: e.target.value }));
@@ -22,12 +35,16 @@ export default function LoginPage() {
navigate('/dashboard'); navigate('/dashboard');
} catch (err) { } catch (err) {
const code = err.response?.data?.code; const code = err.response?.data?.code;
const message = err.response?.data?.error || 'Error al iniciar sesión'; const message = translateApiError(err, 'Error al iniciar sesión');
if (code === 'EMAIL_NOT_VERIFIED') { if (code === 'EMAIL_NOT_VERIFIED') {
toast.success('Tu cuenta aún no está validada. Te enviamos un nuevo correo de confirmación.'); toast.success('Tu cuenta aún no está validada. Te enviamos un nuevo correo de confirmación.');
} else if (code === 'ACCOUNT_LOCKED') {
toast.error('Tu cuenta fue bloqueada temporalmente. Revisa tu correo para desbloquear el sistema.');
} else if (code === 'MAIL_SEND_FAILED') { } else if (code === 'MAIL_SEND_FAILED') {
toast.error(message); toast.error(message);
} else if (code === 'UNLOCK_MAIL_SEND_FAILED') {
toast.error(message);
} else { } else {
toast.error(message); toast.error(message);
} }
@@ -56,14 +73,17 @@ export default function LoginPage() {
<button type="submit" className="btn btn-primary btn-full" disabled={loading}> <button type="submit" className="btn btn-primary btn-full" disabled={loading}>
{loading ? 'Ingresando...' : 'Iniciar sesión'} {loading ? 'Ingresando...' : 'Iniciar sesión'}
</button> </button>
{showAndroidDownload && (
<a className="btn btn-secondary btn-full auth-download-btn" href={androidApkUrl}> <a className="btn btn-secondary btn-full auth-download-btn" href={androidApkUrl}>
<Download size={14} /> <Download size={14} />
Descargar app Android Descargar app Android
</a> </a>
)}
</form> </form>
<p className="auth-link"> <p className="auth-link">
¿No tienes cuenta? <Link to="/register">Registrarse</Link> ¿No tienes cuenta? <Link to="/register">Registrarse</Link>
</p> </p>
<p className="auth-version">Versión {__APP_VERSION__}</p>
</div> </div>
</div> </div>
); );
+243 -11
View File
@@ -1,8 +1,46 @@
import { useState } from 'react'; import { useEffect, useState } from 'react';
import { useNavigate, Link } from 'react-router-dom'; import { useNavigate, Link } from 'react-router-dom';
import { register } from '../../api/auth'; import { register } from '../../api/auth';
import { PenTool } from 'lucide-react'; import { CheckSquare, FileCheck2, FileText, PenTool, ShieldCheck, Tag, X } from 'lucide-react';
import { Document, Page, pdfjs } from 'react-pdf';
import { getLegalTemplatePreview, getLegalTemplates } from '../../api/public';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { translateApiError } from '../../utils/session';
import 'react-pdf/dist/Page/AnnotationLayer.css';
import 'react-pdf/dist/Page/TextLayer.css';
pdfjs.GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/build/pdf.worker.min.mjs',
import.meta.url,
).toString();
const FALLBACK_LEGAL_TEMPLATES = [
{
slug: 'terms-of-use',
title: 'Términos de Uso de LinkedSign',
version: '1.1.0',
effectiveDate: '2026-04-07',
summary: 'Condiciones de acceso y uso de LinkedSign.',
sections: [],
},
{
slug: 'privacy-policy',
title: 'Política de Privacidad y Aviso de Tratamiento de Datos',
version: '1.1.0',
effectiveDate: '2026-04-07',
summary: 'Información sobre tratamiento de datos personales conforme a la LOPDP.',
sections: [],
},
{
slug: 'data-processing-consent',
title: 'Consentimiento Expreso para Tratamiento de Datos y Uso de Firma Electrónica',
version: '1.1.0',
effectiveDate: '2026-04-07',
summary: 'Aceptación expresa del tratamiento de datos y del uso del certificado para el alta inicial.',
sections: [],
},
];
const getPasswordChecks = (password = '') => ([ const getPasswordChecks = (password = '') => ([
{ label: 'Al menos 8 caracteres', valid: password.length >= 8 }, { label: 'Al menos 8 caracteres', valid: password.length >= 8 },
@@ -14,12 +52,68 @@ const getPasswordChecks = (password = '') => ([
export default function RegisterPage() { export default function RegisterPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const [form, setForm] = useState({ email: '', password: '', confirmPassword: '' }); const [form, setForm] = useState({
email: '',
password: '',
confirmPassword: '',
certPassword: '',
consentAccepted: false,
});
const [certificateFile, setCertificateFile] = useState(null);
const [legalTemplates, setLegalTemplates] = useState([]);
const [openedTemplates, setOpenedTemplates] = useState({});
const [acceptedTemplates, setAcceptedTemplates] = useState({});
const [activeTemplate, setActiveTemplate] = useState(null);
const [viewerBlob, setViewerBlob] = useState(null);
const [loadingViewer, setLoadingViewer] = useState(false);
const [numPages, setNumPages] = useState(null);
const [currentPage, setCurrentPage] = useState(1);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const passwordChecks = getPasswordChecks(form.password); const passwordChecks = getPasswordChecks(form.password);
const isPasswordStrong = passwordChecks.every((check) => check.valid); const isPasswordStrong = passwordChecks.every((check) => check.valid);
const templatesToRender = legalTemplates.length > 0 ? legalTemplates : FALLBACK_LEGAL_TEMPLATES;
const allLegalAccepted =
templatesToRender.length > 0 && templatesToRender.every((template) => acceptedTemplates[template.slug]);
const handleChange = (e) => setForm((f) => ({ ...f, [e.target.name]: e.target.value })); useEffect(() => {
getLegalTemplates()
.then(({ data }) => setLegalTemplates(data.data || []))
.catch(() => {
setLegalTemplates([]);
});
}, []);
const handleChange = (e) => {
const { name, value, checked, type } = e.target;
setForm((f) => ({ ...f, [name]: type === 'checkbox' ? checked : value }));
};
const handleOpenTemplate = (template) => {
setActiveTemplate(template);
setOpenedTemplates((current) => ({ ...current, [template.slug]: true }));
setCurrentPage(1);
setNumPages(null);
setLoadingViewer(true);
getLegalTemplatePreview(template.slug)
.then(({ data }) => setViewerBlob(data))
.catch(() => {
setViewerBlob(null);
toast.error('No se pudo abrir el documento legal');
})
.finally(() => setLoadingViewer(false));
};
const handleTemplateAcceptance = (slug, checked) => {
setAcceptedTemplates((current) => ({ ...current, [slug]: checked }));
};
const closeViewer = () => {
setActiveTemplate(null);
setViewerBlob(null);
setLoadingViewer(false);
setNumPages(null);
setCurrentPage(1);
};
const handleSubmit = async (e) => { const handleSubmit = async (e) => {
e.preventDefault(); e.preventDefault();
@@ -31,16 +125,32 @@ export default function RegisterPage() {
toast.error('Las contraseñas no coinciden'); toast.error('Las contraseñas no coinciden');
return; return;
} }
if (!certificateFile) {
toast.error('Debes cargar tu certificado P12/PFX');
return;
}
if (!form.certPassword) {
toast.error('Debes ingresar la contraseña del certificado');
return;
}
if (!allLegalAccepted) {
toast.error('Debes leer y aceptar cada documento legal para completar el registro');
return;
}
setLoading(true); setLoading(true);
try { try {
await register({ const payload = new FormData();
email: form.email, payload.append('email', form.email);
password: form.password, payload.append('password', form.password);
}); payload.append('certPassword', form.certPassword);
toast.success('Cuenta creada. Revisa tu correo para confirmar tu usuario.'); payload.append('consentAccepted', 'true');
payload.append('certificateFile', certificateFile);
await register(payload);
toast.success('Cuenta creada. Revisa tu correo para confirmar tu usuario y recibir los documentos legales firmados.');
navigate('/login'); navigate('/login');
} catch (err) { } catch (err) {
toast.error(err.response?.data?.error || 'Error al registrarse'); toast.error(translateApiError(err, 'Error al registrarse'));
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -78,7 +188,74 @@ export default function RegisterPage() {
<label>Confirmar contraseña</label> <label>Confirmar contraseña</label>
<input type="password" name="confirmPassword" value={form.confirmPassword} onChange={handleChange} required /> <input type="password" name="confirmPassword" value={form.confirmPassword} onChange={handleChange} required />
</div> </div>
<button type="submit" className="btn btn-primary btn-full" disabled={loading}> <div className="field">
<label>Certificado P12/PFX</label>
<input type="file" accept=".p12,.pfx" onChange={(e) => setCertificateFile(e.target.files?.[0] || null)} required />
</div>
<div className="field">
<label>Contraseña del certificado</label>
<input type="password" name="certPassword" value={form.certPassword} onChange={handleChange} required />
<small className="field-hint">
Esta contraseña solo se usa para firmar los documentos legales del registro inicial. No se guarda.
</small>
</div>
<div className="legal-register-box">
<div className="legal-register-header">
<ShieldCheck size={18} />
<div>
<strong>Documentos legales del registro</strong>
<p>Debes abrir cada documento, leerlo y aceptarlo. Se firmarán automáticamente al final de cada documento con tu propio certificado.</p>
</div>
</div>
<div className="legal-register-list">
{templatesToRender.map((template) => (
<div key={template.slug} className="legal-template-row">
<div className="legal-template-stack">
<button
type="button"
className="legal-template-button"
onClick={() => handleOpenTemplate(template)}
>
<FileText size={15} />
<span>{template.title}</span>
</button>
<small className="legal-template-version">
<Tag size={12} />
<span>Versión {template.version}</span>
</small>
<label className={`legal-template-check ${openedTemplates[template.slug] ? '' : 'is-disabled'}`}>
<input
type="checkbox"
checked={Boolean(acceptedTemplates[template.slug])}
onChange={(event) => {
if (!openedTemplates[template.slug]) {
event.preventDefault();
toast.error('Primero debes abrir y leer este documento');
return;
}
handleTemplateAcceptance(template.slug, event.target.checked);
}}
/>
<span>He leído y acepto</span>
</label>
</div>
</div>
))}
</div>
<div className={`legal-register-status ${allLegalAccepted ? 'is-complete' : ''}`}>
<CheckSquare size={16} />
<span>
{allLegalAccepted
? 'Todos los documentos legales fueron leídos y aceptados.'
: 'Debes abrir y aceptar cada documento para continuar.'}
</span>
</div>
</div>
<button
type="submit"
className="btn btn-primary btn-full"
disabled={loading || !allLegalAccepted}
>
{loading ? 'Registrando...' : 'Crear cuenta'} {loading ? 'Registrando...' : 'Crear cuenta'}
</button> </button>
</form> </form>
@@ -86,6 +263,61 @@ export default function RegisterPage() {
¿Ya tienes cuenta? <Link to="/login">Iniciar sesión</Link> ¿Ya tienes cuenta? <Link to="/login">Iniciar sesión</Link>
</p> </p>
</div> </div>
{activeTemplate && (
<div className="modal-overlay" onClick={closeViewer}>
<div className="legal-document-modal" onClick={(event) => event.stopPropagation()}>
<div className="pdf-viewer-header">
<div className="pdf-viewer-title">
<FileCheck2 size={18} />
<span>{activeTemplate.title}</span>
</div>
<div className="pdf-viewer-controls">
{numPages && (
<>
<button type="button" className="btn btn-ghost" disabled={currentPage <= 1} onClick={() => setCurrentPage((page) => page - 1)}>‹</button>
<span>{currentPage} / {numPages}</span>
<button type="button" className="btn btn-ghost" disabled={currentPage >= numPages} onClick={() => setCurrentPage((page) => page + 1)}>›</button>
</>
)}
<button type="button" className="btn-icon" onClick={closeViewer} aria-label="Cerrar">
<X size={18} />
</button>
</div>
</div>
<div className="pdf-viewer-body">
{loadingViewer ? (
<div className="loading-area"><div className="spinner" /></div>
) : viewerBlob ? (
<Document
file={viewerBlob}
onLoadSuccess={({ numPages: totalPages }) => setNumPages(totalPages)}
loading={<div className="loading-area"><div className="spinner" /></div>}
>
<Page pageNumber={currentPage} width={720} />
</Document>
) : (
<div className="loading-area">No se pudo cargar la vista previa.</div>
)}
</div>
<div className="legal-document-footer">
<div className="legal-document-meta">
<span>Versión {activeTemplate.version}</span>
<span>Vigente desde {activeTemplate.effectiveDate}</span>
</div>
<button type="button" className="btn btn-primary" onClick={closeViewer}>
Cerrar
</button>
</div>
</div>
</div>
)}
</div> </div>
); );
} }
const closeViewer = () => {
setActiveTemplate(null);
setViewerBlob(null);
setLoadingViewer(false);
setNumPages(null);
setCurrentPage(1);
};
@@ -0,0 +1,155 @@
import { useEffect, useMemo, useState } from 'react';
import { Link, useParams } from 'react-router-dom';
import { AlertTriangle, CheckCircle2, KeyRound, LoaderCircle, LockKeyhole } from 'lucide-react';
import { getUnlockAccountRequest, requestUnlockPasswordChange } from '../../api/auth';
const getPasswordChecks = (password = '') => ([
{ label: 'Al menos 8 caracteres', valid: password.length >= 8 },
{ label: 'Una mayúscula', valid: /[A-Z]/.test(password) },
{ label: 'Una minúscula', valid: /[a-z]/.test(password) },
{ label: 'Un número', valid: /\d/.test(password) },
{ label: 'Un carácter especial', valid: /[^A-Za-z0-9]/.test(password) },
]);
export default function UnlockAccountPage() {
const { token } = useParams();
const [status, setStatus] = useState('loading');
const [message, setMessage] = useState('Validando el enlace de desbloqueo...');
const [accountInfo, setAccountInfo] = useState(null);
const [form, setForm] = useState({
certPassword: '',
newPassword: '',
confirmPassword: '',
});
const [submitting, setSubmitting] = useState(false);
const passwordChecks = useMemo(() => getPasswordChecks(form.newPassword), [form.newPassword]);
const isPasswordStrong = passwordChecks.every((item) => item.valid);
useEffect(() => {
let active = true;
getUnlockAccountRequest(token)
.then(({ data }) => {
if (!active) return;
setAccountInfo(data.data);
setStatus('ready');
setMessage('Valida tu contraseña de firma registrada y define una nueva contraseña de acceso.');
})
.catch((error) => {
if (!active) return;
setStatus('error');
setMessage(error.response?.data?.error || 'El enlace de desbloqueo no es válido o expiró.');
});
return () => {
active = false;
};
}, [token]);
const handleChange = (event) => {
const { name, value } = event.target;
setForm((current) => ({ ...current, [name]: value }));
};
const handleSubmit = async (event) => {
event.preventDefault();
if (!isPasswordStrong) {
setStatus('ready');
setMessage('La nueva contraseña no cumple con los requisitos de seguridad.');
return;
}
if (form.newPassword !== form.confirmPassword) {
setStatus('ready');
setMessage('Las contraseñas no coinciden.');
return;
}
setSubmitting(true);
try {
const { data } = await requestUnlockPasswordChange(token, {
certPassword: form.certPassword,
newPassword: form.newPassword,
});
setStatus('success');
setMessage(data.data.message || 'Te enviamos un correo para confirmar el cambio de contraseña.');
} catch (error) {
setStatus('ready');
setMessage(error.response?.data?.error || 'No se pudo iniciar el proceso de desbloqueo.');
} finally {
setSubmitting(false);
}
};
return (
<div className="auth-page">
<div className="auth-card">
<div className="auth-logo">
{status === 'loading' && <LoaderCircle size={32} className="spin-soft" />}
{status === 'ready' && <LockKeyhole size={32} />}
{status === 'success' && <CheckCircle2 size={32} />}
{status === 'error' && <AlertTriangle size={32} />}
<h1>Desbloquear sistema</h1>
<p>{message}</p>
</div>
{status === 'ready' && (
<form onSubmit={handleSubmit} className="auth-form">
<div className="sign-confirm-details">
<strong>{accountInfo?.email}</strong>
<span>Certificado: {accountInfo?.certificateAlias || accountInfo?.certificateSubject || 'Registrado'}</span>
</div>
<div className="field">
<label>Contraseña de la firma registrada</label>
<input type="password" name="certPassword" value={form.certPassword} onChange={handleChange} required />
<small className="field-hint">Usaremos esta contraseña solo para validar tu certificado activo.</small>
</div>
<div className="field">
<label>Nueva contraseña</label>
<input type="password" name="newPassword" value={form.newPassword} onChange={handleChange} required />
<div className="password-rules">
{passwordChecks.map((check) => (
<div
key={check.label}
className={`password-rule ${check.valid ? 'password-rule--ok' : 'password-rule--pending'}`}
>
<span>{check.valid ? '✓' : '•'}</span>
<span>{check.label}</span>
</div>
))}
</div>
</div>
<div className="field">
<label>Confirmar nueva contraseña</label>
<input type="password" name="confirmPassword" value={form.confirmPassword} onChange={handleChange} required />
</div>
<button type="submit" className="btn btn-primary btn-full" disabled={submitting}>
<KeyRound size={16} />
{submitting ? 'Procesando...' : 'Solicitar desbloqueo y cambio'}
</button>
</form>
)}
{status === 'success' && (
<div className="auth-form">
<Link to="/login" className="btn btn-primary btn-full">
Ir a iniciar sesión
</Link>
</div>
)}
{status === 'error' && (
<div className="auth-form">
<Link to="/login" className="btn btn-secondary btn-full">
Volver al login
</Link>
</div>
)}
</div>
</div>
);
}
+331 -13
View File
@@ -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';
@@ -22,9 +23,13 @@ const STATUS_LABELS = {
export default function DocumentsPage() { export default function DocumentsPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const pageSizeOptions = [5, 10, 15, 20];
const [docs, setDocs] = useState([]); const [docs, setDocs] = useState([]);
const [signedDocs, setSignedDocs] = useState([]); const [signedDocs, setSignedDocs] = useState([]);
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
const [pageSize, setPageSize] = useState(5);
const [docsPage, setDocsPage] = useState(1);
const [signedPage, setSignedPage] = useState(1);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const fileInputRef = useRef(); const fileInputRef = useRef();
@@ -35,33 +40,53 @@ 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 = async ({ silent = false } = {}) => {
setLoading(true); if (!silent) setLoading(true);
Promise.all([ try {
const [documentsResponse, signedResponse] = await Promise.all([
listDocuments({ limit: 50 }), listDocuments({ limit: 50 }),
listSignedDocuments(), listSignedDocuments(),
]) ]);
.then(([documentsResponse, signedResponse]) => {
setDocs(documentsResponse.data.data.rows); setDocs(documentsResponse.data.data.rows);
setTotal(documentsResponse.data.data.total); setTotal(documentsResponse.data.data.total);
setSignedDocs(signedResponse.data.data || []); setSignedDocs(signedResponse.data.data || []);
}) } catch {
.catch(() => toast.error('Error al cargar documentos')) toast.error('Error al cargar documentos');
.finally(() => setLoading(false)); } finally {
if (!silent) setLoading(false);
}
}; };
useEffect(() => { loadDocs(); }, []); useEffect(() => { loadDocs(); }, []);
useEffect(() => { setDocsPage(1); }, [pageSize, docs.length]);
useEffect(() => { setSignedPage(1); }, [pageSize, signedDocs.length]);
const docsTotalPages = Math.max(1, Math.ceil(docs.length / pageSize));
const signedTotalPages = Math.max(1, Math.ceil(signedDocs.length / pageSize));
const pagedDocs = docs.slice((docsPage - 1) * pageSize, docsPage * pageSize);
const pagedSignedDocs = signedDocs.slice((signedPage - 1) * pageSize, signedPage * pageSize);
const handleUpload = async (e) => { const handleUpload = async (e) => {
const file = e.target.files[0]; const file = e.target.files[0];
if (!file) return; if (!file) return;
if (file.type !== 'application/pdf') { toast.error('Solo se permiten archivos PDF'); return; } if (file.type !== 'application/pdf') { toast.error('Solo se permiten archivos PDF'); return; }
setUploading(true); setUploading(true);
try { try {
await uploadDocument(file); const { data } = await uploadDocument(file);
const createdDoc = data.data;
setDocs((currentDocs) => [createdDoc, ...currentDocs.filter((doc) => doc.id !== createdDoc.id)]);
setTotal((currentTotal) => currentTotal + 1);
toast.success('Documento subido correctamente'); toast.success('Documento subido correctamente');
loadDocs(); void loadDocs({ silent: true });
} catch (err) { } catch (err) {
toast.error(err.response?.data?.error || 'Error al subir documento'); toast.error(err.response?.data?.error || 'Error al subir documento');
} finally { } finally {
@@ -115,6 +140,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">
@@ -154,12 +272,191 @@ 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>
<p>{total} documento{total !== 1 ? 's' : ''} original{total !== 1 ? 'es' : ''}</p> <p>{total} documento{total !== 1 ? 's' : ''} original{total !== 1 ? 'es' : ''}</p>
</div> </div>
<div className="header-actions"> <div className="header-actions">
<div className="documents-page-size">
<label htmlFor="documents-page-size">Mostrar</label>
<select
id="documents-page-size"
value={pageSize}
onChange={(e) => setPageSize(Number(e.target.value))}
>
{pageSizeOptions.map((option) => (
<option key={option} value={option}>{option}</option>
))}
</select>
</div>
<button className="btn btn-ghost" onClick={loadDocs} title="Actualizar"><RefreshCw size={16} /></button> <button className="btn btn-ghost" onClick={loadDocs} title="Actualizar"><RefreshCw size={16} /></button>
<button className="btn btn-primary" onClick={() => fileInputRef.current?.click()} disabled={uploading}> <button className="btn btn-primary" onClick={() => fileInputRef.current?.click()} disabled={uploading}>
<Upload size={16} /> {uploading ? 'Subiendo...' : 'Subir PDF'} <Upload size={16} /> {uploading ? 'Subiendo...' : 'Subir PDF'}
@@ -194,7 +491,7 @@ export default function DocumentsPage() {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{docs.map((doc) => { {pagedDocs.map((doc) => {
const s = STATUS_LABELS[doc.status] || STATUS_LABELS.pending; const s = STATUS_LABELS[doc.status] || STATUS_LABELS.pending;
return ( return (
<tr key={doc.id}> <tr key={doc.id}>
@@ -223,6 +520,15 @@ export default function DocumentsPage() {
</table> </table>
</div> </div>
)} )}
{docs.length > 0 && (
<div className="audit-pagination">
<span>Página {docsPage} de {docsTotalPages}</span>
<div className="header-actions">
<button className="btn btn-secondary" disabled={docsPage <= 1} onClick={() => setDocsPage((current) => Math.max(1, current - 1))}>Anterior</button>
<button className="btn btn-secondary" disabled={docsPage >= docsTotalPages} onClick={() => setDocsPage((current) => Math.min(docsTotalPages, current + 1))}>Siguiente</button>
</div>
</div>
)}
</section> </section>
<section className="documents-section"> <section className="documents-section">
@@ -248,7 +554,7 @@ export default function DocumentsPage() {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{signedDocs.map((doc) => ( {pagedSignedDocs.map((doc) => (
<tr key={doc.id}> <tr key={doc.id}>
<td className="doc-name"><FileText size={14} /> {doc.signed_filename}</td> <td className="doc-name"><FileText size={14} /> {doc.signed_filename}</td>
<td>{doc.document_title}</td> <td>{doc.document_title}</td>
@@ -258,6 +564,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>
@@ -268,6 +577,15 @@ export default function DocumentsPage() {
</table> </table>
</div> </div>
)} )}
{signedDocs.length > 0 && (
<div className="audit-pagination">
<span>Página {signedPage} de {signedTotalPages}</span>
<div className="header-actions">
<button className="btn btn-secondary" disabled={signedPage <= 1} onClick={() => setSignedPage((current) => Math.max(1, current - 1))}>Anterior</button>
<button className="btn btn-secondary" disabled={signedPage >= signedTotalPages} onClick={() => setSignedPage((current) => Math.min(signedTotalPages, current + 1))}>Siguiente</button>
</div>
</div>
)}
</section> </section>
</div> </div>
)} )}
@@ -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>
);
}

Some files were not shown because too many files have changed in this diff Show More