Compare commits
62 Commits
408a9b4435
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 8d17393d5f | |||
| 848441d593 | |||
| 99a5350008 | |||
| 82f3d2cbf9 | |||
| ff7ff92d84 | |||
| b6c169ffbb | |||
| 8d090dc8ca | |||
| 7fc4707724 | |||
| bc28441531 | |||
| 3c9aefa10e | |||
| 81bd8d3c59 | |||
| fdde9cdb5d | |||
| 5ed70766b9 | |||
| 2a0f272af1 | |||
| e67e7aa122 | |||
| f19f8bc899 | |||
| dfcc0ca37b | |||
| 8308e3537e | |||
| 8bc244855c | |||
| 5d7d610322 | |||
| 92d934bb36 | |||
| 58ab0311df | |||
| 199e62e641 | |||
| 505bfa0efd | |||
| 55837a2982 | |||
| b9f5165e38 | |||
| df7d07459a | |||
| fa5cd053f6 | |||
| 24fe8d0a0b | |||
| b24d17b98e | |||
| 892fb31c18 | |||
| 14a3d9f497 | |||
| 51b2116bde | |||
| d80d537d02 | |||
| 5f27b1562e | |||
| df123e2cf4 | |||
| a96907b684 | |||
| 51074d08c6 | |||
| 1b930e1737 | |||
| eb3f47423f | |||
| 8194c4dee4 | |||
| ae5649f408 | |||
| 622048b163 | |||
| 9e0fbcaa0d | |||
| 2cbd0f2ca3 | |||
| 62cb604412 | |||
| 820e335d9a | |||
| da4b06094e | |||
| 331dd40e76 | |||
| f1bfa5aafc | |||
| 1e965b6e71 | |||
| 46ec312706 | |||
| 946c19a397 | |||
| 89b09cd8ac | |||
| 15e8d55fca | |||
| 0292b2e85a | |||
| 890f3fff2b | |||
| eec4977bb2 | |||
| 007b5b4b9e | |||
| 605ea94041 | |||
| 9a8e70c943 | |||
| 46a11728b8 |
@@ -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/
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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,17 +245,31 @@ 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}")
|
||||||
val updated = session.copy(
|
|
||||||
accessToken = refresh.accessToken,
|
|
||||||
refreshToken = refresh.refreshToken
|
|
||||||
)
|
|
||||||
sessionStore.save(updated)
|
|
||||||
block("Bearer ${refresh.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(
|
||||||
|
accessToken = refresh.accessToken,
|
||||||
|
refreshToken = refresh.refreshToken
|
||||||
|
)
|
||||||
|
sessionStore.save(updated)
|
||||||
|
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 {
|
||||||
return try {
|
return try {
|
||||||
block()
|
block()
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
|
After Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
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>
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ Valor actual:
|
|||||||
{
|
{
|
||||||
"expo": {
|
"expo": {
|
||||||
"extra": {
|
"extra": {
|
||||||
"apiBaseUrl": "http://192.168.222.72:4000"
|
"apiBaseUrl": "http://192.168.222.40:4000"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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=
|
||||||
|
|||||||
@@ -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,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",
|
||||||
|
|||||||
@@ -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'));
|
||||||
|
|||||||
@@ -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'),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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, '&')
|
||||||
const subject = 'Confirma tu cuenta de LinkedSign';
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
|
||||||
|
const buildBaseUrl = () => env.frontendUrl.replace(/\/$/, '');
|
||||||
|
|
||||||
|
const supportEmail = () => env.mail.from;
|
||||||
|
|
||||||
|
const renderEmailLayout = ({ preheader, title, intro, bodyHtml, ctaLabel, ctaUrl, footerNote }) => `
|
||||||
|
<div style="display:none;max-height:0;overflow:hidden;opacity:0;color:transparent;">
|
||||||
|
${escapeHtml(preheader)}
|
||||||
|
</div>
|
||||||
|
<div style="margin:0;padding:24px;background:#f3f6fb;font-family:Arial,sans-serif;color:#14213d;">
|
||||||
|
<div style="max-width:620px;margin:0 auto;background:#ffffff;border:1px solid #d9e2f0;border-radius:14px;overflow:hidden;">
|
||||||
|
<div style="padding:18px 24px;border-bottom:1px solid #e5ecf5;background:#f8fbff;">
|
||||||
|
<div style="font-size:18px;font-weight:700;color:#1d4ed8;">LinkedSign</div>
|
||||||
|
<div style="font-size:12px;color:#64748b;margin-top:4px;">Notificación transaccional del sistema</div>
|
||||||
|
</div>
|
||||||
|
<div style="padding:24px;">
|
||||||
|
<h2 style="margin:0 0 14px;font-size:22px;line-height:1.3;color:#0f172a;">${escapeHtml(title)}</h2>
|
||||||
|
<p style="margin:0 0 16px;font-size:14px;line-height:1.6;color:#334155;">${intro}</p>
|
||||||
|
${bodyHtml}
|
||||||
|
<div style="margin:24px 0 18px;">
|
||||||
|
<a href="${ctaUrl}" style="background:#2563eb;color:#ffffff;text-decoration:none;padding:12px 18px;border-radius:10px;display:inline-block;font-size:14px;font-weight:700;">
|
||||||
|
${escapeHtml(ctaLabel)}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<p style="margin:0 0 10px;font-size:12px;line-height:1.6;color:#64748b;">
|
||||||
|
Si el botón no funciona, copia y pega este enlace en tu navegador:
|
||||||
|
</p>
|
||||||
|
<p style="margin:0 0 18px;font-size:12px;word-break:break-all;">
|
||||||
|
<a href="${ctaUrl}" style="color:#2563eb;text-decoration:none;">${ctaUrl}</a>
|
||||||
|
</p>
|
||||||
|
${footerNote ? `<p style="margin:0;font-size:12px;line-height:1.6;color:#64748b;">${footerNote}</p>` : ''}
|
||||||
|
</div>
|
||||||
|
<div style="padding:14px 24px;border-top:1px solid #e5ecf5;background:#fafcff;font-size:11px;line-height:1.6;color:#64748b;">
|
||||||
|
Mensaje enviado por LinkedSign desde ${escapeHtml(buildBaseUrl())}. Si necesitas ayuda, responde a ${escapeHtml(supportEmail())}.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const sendVerificationEmail = async ({ name, email, verificationToken, attachments = [] }) => {
|
||||||
|
const 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 || '';
|
||||||
|
|||||||
@@ -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,10 +33,103 @@ 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) ──────────────────────────────────────
|
||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|
||||||
app.listen(env.port, () => {
|
const start = async () => {
|
||||||
console.log(`LinkedSign backend running on port ${env.port}`);
|
await authRepository.ensureSchema();
|
||||||
|
await invitationsRepository.ensureSchema();
|
||||||
|
|
||||||
|
app.listen(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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,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,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",
|
||||||
|
|||||||
|
After Width: | Height: | Size: 6.6 KiB |
|
Before Width: | Height: | Size: 9.3 KiB After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 31 KiB |
@@ -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>
|
||||||
|
|||||||
@@ -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 });
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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' },
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -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',
|
||||||
|
});
|
||||||
@@ -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' });
|
||||||
|
|||||||
@@ -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,35 +245,37 @@ export default function PdfSignaturePlacer({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div ref={stageRef} className="pdf-placer-stage">
|
||||||
ref={containerRef}
|
<div
|
||||||
className="pdf-placer-canvas"
|
ref={containerRef}
|
||||||
onClick={handleContainerClick}
|
className="pdf-placer-canvas"
|
||||||
style={{ cursor: 'crosshair', position: 'relative', display: 'inline-block' }}
|
onPointerDown={handlePointerDown}
|
||||||
>
|
style={{ cursor: 'crosshair', position: 'relative', display: 'inline-block' }}
|
||||||
<Document file={pdfBlob} onLoadSuccess={handleDocLoad}>
|
>
|
||||||
<Page
|
<Document file={pdfBlob} onLoadSuccess={handleDocLoad}>
|
||||||
pageNumber={currentPage}
|
<Page
|
||||||
width={600}
|
pageNumber={currentPage}
|
||||||
onLoadSuccess={(p) => setPageSize({
|
width={pageRenderWidth}
|
||||||
width: p.width, height: p.height,
|
onLoadSuccess={(p) => setPageSize({
|
||||||
pdfWidth: p.originalWidth, pdfHeight: p.originalHeight,
|
width: p.width, height: p.height,
|
||||||
})}
|
pdfWidth: p.originalWidth, pdfHeight: p.originalHeight,
|
||||||
/>
|
})}
|
||||||
</Document>
|
/>
|
||||||
|
</Document>
|
||||||
|
|
||||||
{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 && (
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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`;
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
<a className="btn btn-secondary btn-full auth-download-btn" href={androidApkUrl}>
|
{showAndroidDownload && (
|
||||||
<Download size={14} />
|
<a className="btn btn-secondary btn-full auth-download-btn" href={androidApkUrl}>
|
||||||
Descargar app Android
|
<Download size={14} />
|
||||||
</a>
|
Descargar app Android
|
||||||
|
</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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
listDocuments({ limit: 50 }),
|
const [documentsResponse, signedResponse] = await Promise.all([
|
||||||
listSignedDocuments(),
|
listDocuments({ limit: 50 }),
|
||||||
])
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||