Compare commits
64 Commits
78d0871895
..
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 | |||
| 408a9b4435 | |||
| 64e0757f3b |
@@ -8,7 +8,6 @@ backend/.env.*
|
||||
frontend/.env
|
||||
frontend/.env.*
|
||||
!frontend/.env.example
|
||||
android/local.properties
|
||||
android/keystore.properties
|
||||
android/*.jks
|
||||
android/*.keystore
|
||||
@@ -68,8 +67,10 @@ android_react/ios/
|
||||
# --- Editor / OS ---
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
.token_gitea
|
||||
.idea/
|
||||
.codex
|
||||
.tmp-venv/
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
*.swp
|
||||
|
||||
@@ -170,3 +170,110 @@ Always suggest when a skill can be applied.
|
||||
## Goal
|
||||
|
||||
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
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
# Changelog
|
||||
|
||||
## Versión actual
|
||||
|
||||
- Versión base: `1.2.35`
|
||||
|
||||
## 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
|
||||
|
||||
- Se unifica la versión inicial de `frontend`, `backend` y `android`.
|
||||
- Se establece `1.0.1` como versión base de arranque del sistema.
|
||||
- Se agrega el archivo `VERSION` como fuente única de versión.
|
||||
- Se agrega el script `scripts/set-version.sh` para actualizar versiones de forma sincronizada.
|
||||
|
||||
## Cómo registrar una nueva versión
|
||||
|
||||
1. Ejecuta `./scripts/set-version.sh X.Y.Z`
|
||||
2. Actualiza este archivo con la nueva versión y sus cambios principales.
|
||||
3. Haz las pruebas normales del sistema.
|
||||
4. Guarda cambios en git y despliega.
|
||||
@@ -0,0 +1,50 @@
|
||||
# Deploy Android
|
||||
|
||||
## Versión actual
|
||||
|
||||
- Base actual: `1.2.34`
|
||||
- Fuente de verdad: [VERSION](/home/mgonzalez/desarrollo/react/linkedsign/VERSION)
|
||||
- Historial de cambios: [CHANGELOG.md](/home/mgonzalez/desarrollo/react/linkedsign/CHANGELOG.md)
|
||||
|
||||
## Pasos
|
||||
|
||||
1. Haz los cambios en `dev`.
|
||||
2. Si corresponde, sube versión:
|
||||
- `./scripts/set-version.sh X.Y.Z`
|
||||
- actualiza `CHANGELOG.md`
|
||||
- confirma que `android/app/build.gradle` refleje el nuevo `versionName` y `versionCode`
|
||||
3. Prueba la app:
|
||||
- `source /etc/profile.d/android-sdk.sh`
|
||||
- `cd android`
|
||||
- `GRADLE_USER_HOME=/home/mgonzalez/desarrollo/react/linkedsign/android/.gradle-home ./gradlew assembleDebug`
|
||||
4. Si todo está bien, genera release:
|
||||
- `GRADLE_USER_HOME=/home/mgonzalez/desarrollo/react/linkedsign/android/.gradle-home ./gradlew assembleRelease`
|
||||
5. Copia el APK release:
|
||||
- `cp android/app/build/outputs/apk/release/app-release.apk android/apk/LinkedSign-release.apk`
|
||||
6. Guarda cambios en git:
|
||||
- `git add .`
|
||||
- `git commit -m "mensaje"`
|
||||
7. Sube a Gitea:
|
||||
- `git push origin main`
|
||||
8. Sube el APK a producción:
|
||||
- `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:
|
||||
- `find /opt/apps/linkedsign -type f -iname '*.md' -delete`
|
||||
10. Verifica descarga pública:
|
||||
- `https://sign.linkedcorp.net/api/downloads/android-apk`
|
||||
|
||||
## Notas
|
||||
|
||||
- La `keystore` de producción está fuera de git.
|
||||
- No pierdas la `keystore` ni las contraseñas.
|
||||
- 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.
|
||||
- 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
|
||||
@@ -0,0 +1,64 @@
|
||||
# Deploy Web
|
||||
|
||||
## Versión actual
|
||||
|
||||
- Base actual: `1.2.34`
|
||||
- Fuente de verdad: [VERSION](/home/mgonzalez/desarrollo/react/linkedsign/VERSION)
|
||||
- Historial de cambios: [CHANGELOG.md](/home/mgonzalez/desarrollo/react/linkedsign/CHANGELOG.md)
|
||||
|
||||
## Pasos
|
||||
|
||||
1. Haz los cambios en `dev`.
|
||||
2. Si corresponde, sube versión:
|
||||
- `./scripts/set-version.sh X.Y.Z`
|
||||
- actualiza `CHANGELOG.md`
|
||||
3. Prueba local:
|
||||
- `cd frontend && npm run build`
|
||||
- `cd ../backend && node -e "require('./src/app')"`
|
||||
4. Guarda cambios en git:
|
||||
- `git add .`
|
||||
- `git commit -m "mensaje"`
|
||||
5. Sube a Gitea:
|
||||
- `git push origin main`
|
||||
6. En el servidor app entra por SSH:
|
||||
- `ssh deploy@192.168.222.3 -p 2203`
|
||||
7. Actualiza código:
|
||||
- `cd /opt/apps/linkedsign/source`
|
||||
- `git fetch origin main`
|
||||
- `git reset --hard origin/main`
|
||||
- `rm -rf /opt/apps/linkedsign/source/android /opt/apps/linkedsign/source/android_react /opt/apps/linkedsign/source/android_firma_electronica.html`
|
||||
8. En producción usa el script seguro:
|
||||
- `cd /opt/apps/linkedsign/source`
|
||||
- `bash scripts/deploy_prod_web.sh`
|
||||
9. Reinicia servicios:
|
||||
- `sudo systemctl restart linkedsign-frontend`
|
||||
- `sudo systemctl restart linkedsign-backend`
|
||||
- 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`
|
||||
11. Verifica:
|
||||
- `curl -I http://127.0.0.1:3000`
|
||||
- `curl http://127.0.0.1:4000/health`
|
||||
|
||||
## Notas
|
||||
|
||||
- Nunca copies `.env` desde `dev` a `prod`.
|
||||
- El archivo runtime de producción es:
|
||||
- `/opt/apps/linkedsign/backend/.env`
|
||||
- Si cambias variables de entorno, actualiza solo ese archivo runtime.
|
||||
- Si cambias rutas del APK o storage, revisa también `PRODUCCION.md`.
|
||||
- En producción no debe quedar código fuente Android; solo el APK release en `/opt/apps/linkedsign/android/apk/`.
|
||||
- Si en el despliegue hay cambios Android, recuerda que a producción solo pasa el `APK release` versionado; no el proyecto Android completo.
|
||||
- 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
|
||||
@@ -0,0 +1,87 @@
|
||||
# Producción LinkedSign
|
||||
|
||||
## Versión actual
|
||||
|
||||
- Base actual: `1.2.34`
|
||||
- Fuente de verdad: [VERSION](/home/mgonzalez/desarrollo/react/linkedsign/VERSION)
|
||||
- Historial de cambios: [CHANGELOG.md](/home/mgonzalez/desarrollo/react/linkedsign/CHANGELOG.md)
|
||||
|
||||
## Servidor de aplicación
|
||||
|
||||
- Host: `192.168.222.3`
|
||||
- SSH: `deploy@192.168.222.3 -p 2203`
|
||||
- Ruta base: `/opt/apps/linkedsign`
|
||||
|
||||
## Estructura en servidor
|
||||
|
||||
- Código fuente: `/opt/apps/linkedsign/source`
|
||||
- Frontend runtime: `/opt/apps/linkedsign/frontend`
|
||||
- Backend runtime: `/opt/apps/linkedsign/backend`
|
||||
- Storage persistente: `/opt/apps/linkedsign/storage`
|
||||
- Logs: `/opt/apps/linkedsign/logs`
|
||||
|
||||
## Servicios persistentes
|
||||
|
||||
- Backend: `linkedsign-backend.service`
|
||||
- Frontend: `linkedsign-frontend.service`
|
||||
|
||||
## Puertos internos
|
||||
|
||||
- Frontend: `127.0.0.1:3000`
|
||||
- Backend: `127.0.0.1:4000`
|
||||
|
||||
## Estado actual
|
||||
|
||||
- Frontend local responde en `3000`
|
||||
- Backend local responde en `4000`
|
||||
- Ambos servicios quedaron `enabled` y `running`
|
||||
|
||||
## Nginx Proxy Manager
|
||||
|
||||
### Proxy host principal
|
||||
|
||||
- Domain: `sign.linkedcorp.net`
|
||||
- Forward Hostname/IP: `192.168.222.3`
|
||||
- Forward Port: `3000`
|
||||
- Scheme: `http`
|
||||
- SSL: certificado para `sign.linkedcorp.net`
|
||||
|
||||
### Custom location
|
||||
|
||||
- Location: `/api`
|
||||
- Forward Hostname/IP: `192.168.222.3`
|
||||
- Forward Port: `4000`
|
||||
- Scheme: `http`
|
||||
|
||||
No hace falta rewrite. El backend ya acepta `/api`.
|
||||
|
||||
## Base de datos
|
||||
|
||||
- Host MariaDB: `192.168.222.32`
|
||||
- Base de datos: `linkedsign`
|
||||
- Usuario: `linkedsign_user`
|
||||
|
||||
## Estado base confirmado
|
||||
|
||||
- MariaDB en `192.168.222.32` ya permite acceso desde `192.168.222.3`
|
||||
- Tablas de producción ya fueron creadas y validadas
|
||||
- El dominio público operativo es `https://sign.linkedcorp.net`
|
||||
|
||||
## Comandos útiles
|
||||
|
||||
```bash
|
||||
sudo systemctl status linkedsign-frontend
|
||||
sudo systemctl status linkedsign-backend
|
||||
sudo journalctl -u linkedsign-frontend -n 100 --no-pager
|
||||
sudo journalctl -u linkedsign-backend -n 100 --no-pager
|
||||
```
|
||||
|
||||
## Notas de versión
|
||||
|
||||
1. Toda nueva versión debe reflejarse en `VERSION`.
|
||||
2. Toda nueva versión debe registrarse en `CHANGELOG.md`.
|
||||
3. Producción no debe conservar archivos `.md` después del despliegue.
|
||||
|
||||
## Seguridad
|
||||
|
||||
El token de Gitea usado durante esta preparación quedó expuesto en el chat. Debe rotarse.
|
||||
@@ -58,7 +58,7 @@ android/
|
||||
Por defecto la app apunta a:
|
||||
|
||||
```text
|
||||
http://192.168.222.72:4000
|
||||
http://192.168.222.40:4000
|
||||
```
|
||||
|
||||
Ese valor está en:
|
||||
|
||||
@@ -18,8 +18,8 @@ android {
|
||||
applicationId "net.linkedcorp.linkedsign"
|
||||
minSdk 26
|
||||
targetSdk 35
|
||||
versionCode 1
|
||||
versionName "0.1.0"
|
||||
versionCode 10235
|
||||
versionName "1.2.35"
|
||||
|
||||
buildConfigField "String", "API_BASE_URL", "\"${apiBaseUrl}\""
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.WindowInsetsControllerCompat
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import net.linkedcorp.linkedsign.ui.LinkedSignApp
|
||||
import net.linkedcorp.linkedsign.ui.MainViewModel
|
||||
@@ -14,6 +16,7 @@ class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
hideSystemNavigation()
|
||||
|
||||
setContent {
|
||||
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")
|
||||
suspend fun login(@Body body: LoginRequest): ApiEnvelope<LoginData>
|
||||
|
||||
@Multipart
|
||||
@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")
|
||||
suspend fun refresh(@Body body: RefreshRequest): ApiEnvelope<RefreshData>
|
||||
|
||||
@POST("auth/biometric-login")
|
||||
suspend fun biometricLogin(@Body body: BiometricTokenRequest): ApiEnvelope<BiometricLoginData>
|
||||
|
||||
@POST("auth/logout")
|
||||
suspend fun logout(@Body body: RefreshRequest): ApiEnvelope<SimpleMessageData>
|
||||
|
||||
@GET("auth/me")
|
||||
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")
|
||||
suspend fun changePassword(
|
||||
@Header("Authorization") authorization: String,
|
||||
@@ -72,6 +88,11 @@ interface LinkedSignApiService {
|
||||
@Header("Authorization") authorization: String
|
||||
): ApiEnvelope<List<SignedDocumentItem>>
|
||||
|
||||
@GET("legal")
|
||||
suspend fun listLegalDocuments(
|
||||
@Header("Authorization") authorization: String
|
||||
): ApiEnvelope<List<LegalDocumentItem>>
|
||||
|
||||
@DELETE("signatures/{id}")
|
||||
suspend fun deleteSignedDocument(
|
||||
@Header("Authorization") authorization: String,
|
||||
|
||||
@@ -21,6 +21,8 @@ import java.io.FileInputStream
|
||||
import java.io.IOException
|
||||
import java.util.Locale
|
||||
|
||||
private const val TOKEN_REFRESH_MARGIN_MS = 6 * 60 * 1000L
|
||||
|
||||
class LinkedSignRepository(private val context: Context) {
|
||||
private val sessionStore = SessionStore(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 {
|
||||
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() {
|
||||
execute {
|
||||
val session = sessionStore.load()
|
||||
val biometric = sessionStore.loadBiometricState()
|
||||
try {
|
||||
if (!session.refreshToken.isNullOrBlank()) {
|
||||
if (!session.refreshToken.isNullOrBlank() && !biometric.enabled) {
|
||||
unwrap(service.logout(RefreshRequest(session.refreshToken)))
|
||||
}
|
||||
} finally {
|
||||
@@ -99,30 +109,63 @@ class LinkedSignRepository(private val context: Context) {
|
||||
}
|
||||
|
||||
suspend fun biometricLogin(): SessionSnapshot {
|
||||
val credentials = sessionStore.loadBiometricCredentials()
|
||||
?: throw AppException("No hay credenciales biométricas guardadas.")
|
||||
return login(credentials.first, credentials.second)
|
||||
val biometric = sessionStore.loadBiometricToken()
|
||||
?: throw AppException("No hay sesión biométrica guardada.")
|
||||
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 enableBiometricAccess(email: String, password: String) {
|
||||
sessionStore.saveBiometricAccess(email.trim(), password)
|
||||
suspend fun enableBiometricAccess() {
|
||||
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()
|
||||
}
|
||||
|
||||
fun syncBiometricCredentials(email: String, password: String) {
|
||||
sessionStore.updateBiometricCredentials(email.trim(), password)
|
||||
}
|
||||
|
||||
suspend fun requestPasswordChange(currentPassword: String, newPassword: String): SimpleMessageData =
|
||||
execute { withAuth { auth ->
|
||||
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 ->
|
||||
val documents = unwrap(service.listDocuments(auth)).rows
|
||||
val certificates = unwrap(service.listCertificates(auth))
|
||||
@@ -176,7 +219,22 @@ class LinkedSignRepository(private val context: Context) {
|
||||
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 {
|
||||
val session = sessionStore.load()
|
||||
@@ -187,15 +245,29 @@ class LinkedSignRepository(private val context: Context) {
|
||||
} catch (error: HttpException) {
|
||||
if (error.code() != 401) throw mapHttpException(error)
|
||||
|
||||
val refreshToken = session.refreshToken ?: throw AppException("Tu sesión expiró. Ingresa nuevamente.")
|
||||
val refresh = unwrap(service.refresh(RefreshRequest(refreshToken)))
|
||||
val updated = refreshSession(session)
|
||||
block("Bearer ${updated.accessToken}")
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun refreshSession(session: SessionSnapshot): SessionSnapshot {
|
||||
val refreshToken = session.refreshToken ?: throwSessionExpired()
|
||||
val refresh = try {
|
||||
unwrap(service.refresh(RefreshRequest(refreshToken)))
|
||||
} catch (refreshError: Exception) {
|
||||
sessionStore.clearSession()
|
||||
throwSessionExpired()
|
||||
}
|
||||
val updated = session.copy(
|
||||
accessToken = refresh.accessToken,
|
||||
refreshToken = refresh.refreshToken
|
||||
)
|
||||
sessionStore.save(updated)
|
||||
block("Bearer ${refresh.accessToken}")
|
||||
return updated
|
||||
}
|
||||
|
||||
private fun throwSessionExpired(): Nothing {
|
||||
throw AppException("Tu sesión expiró. Vuelve a iniciar sesión.", "SESSION_EXPIRED")
|
||||
}
|
||||
|
||||
private suspend fun <T> execute(block: suspend () -> T): T {
|
||||
|
||||
@@ -43,9 +43,19 @@ data class LoginData(
|
||||
val user: SessionUser
|
||||
)
|
||||
|
||||
data class RegisterRequest(
|
||||
val email: String,
|
||||
val password: String
|
||||
data class BiometricTokenRequest(
|
||||
val biometricToken: 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(
|
||||
@@ -128,6 +138,21 @@ data class SignedDocumentItem(
|
||||
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(
|
||||
val documentId: Int,
|
||||
val certificateId: Int,
|
||||
@@ -161,7 +186,8 @@ data class SignData(
|
||||
data class SessionSnapshot(
|
||||
val accessToken: String? = null,
|
||||
val refreshToken: String? = null,
|
||||
val user: SessionUser? = null
|
||||
val user: SessionUser? = null,
|
||||
val expiresAt: Long? = null
|
||||
)
|
||||
|
||||
data class BiometricAccessState(
|
||||
|
||||
@@ -3,8 +3,10 @@ package net.linkedcorp.linkedsign.data
|
||||
import android.content.Context
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKey
|
||||
import android.util.Base64
|
||||
import com.squareup.moshi.Moshi
|
||||
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
|
||||
import org.json.JSONObject
|
||||
|
||||
class SessionStore(context: Context) {
|
||||
private val moshi = Moshi.Builder().add(KotlinJsonAdapterFactory()).build()
|
||||
@@ -26,15 +28,22 @@ class SessionStore(context: Context) {
|
||||
val accessToken = preferences.getString(KEY_ACCESS, null)
|
||||
val refreshToken = preferences.getString(KEY_REFRESH, 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) }
|
||||
return SessionSnapshot(accessToken, refreshToken, user)
|
||||
return SessionSnapshot(accessToken, refreshToken, user, expiresAt)
|
||||
}
|
||||
|
||||
fun save(session: SessionSnapshot) {
|
||||
val expiresAt = session.expiresAt
|
||||
?: session.accessToken?.let { readAccessTokenExpiry(it) }
|
||||
?: (System.currentTimeMillis() + DEFAULT_SESSION_MS)
|
||||
|
||||
preferences.edit()
|
||||
.putString(KEY_ACCESS, session.accessToken)
|
||||
.putString(KEY_REFRESH, session.refreshToken)
|
||||
.putString(KEY_USER, session.user?.let { userAdapter.toJson(it) })
|
||||
.putLong(KEY_EXPIRES_AT, expiresAt)
|
||||
.apply()
|
||||
}
|
||||
|
||||
@@ -47,29 +56,41 @@ class SessionStore(context: Context) {
|
||||
.remove(KEY_ACCESS)
|
||||
.remove(KEY_REFRESH)
|
||||
.remove(KEY_USER)
|
||||
.remove(KEY_EXPIRES_AT)
|
||||
.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 {
|
||||
val enabled = preferences.getBoolean(KEY_BIO_ENABLED, false)
|
||||
val configured = preferences.getBoolean(KEY_BIO_CONFIGURED, false)
|
||||
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(
|
||||
enabled = enabled,
|
||||
configured = configured,
|
||||
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()
|
||||
.putBoolean(KEY_BIO_ENABLED, true)
|
||||
.putBoolean(KEY_BIO_CONFIGURED, true)
|
||||
.putString(KEY_BIO_EMAIL, email)
|
||||
.putString(KEY_BIO_PASSWORD, password)
|
||||
.putString(KEY_BIO_TOKEN, biometricToken)
|
||||
.apply()
|
||||
}
|
||||
|
||||
@@ -78,37 +99,58 @@ class SessionStore(context: Context) {
|
||||
.putBoolean(KEY_BIO_ENABLED, false)
|
||||
.putBoolean(KEY_BIO_CONFIGURED, true)
|
||||
.remove(KEY_BIO_EMAIL)
|
||||
.remove(KEY_BIO_PASSWORD)
|
||||
.remove(KEY_BIO_TOKEN)
|
||||
.apply()
|
||||
}
|
||||
|
||||
fun updateBiometricCredentials(email: String, password: String) {
|
||||
fun updateBiometricSession(email: String, biometricToken: String) {
|
||||
if (!preferences.getBoolean(KEY_BIO_ENABLED, false)) {
|
||||
return
|
||||
}
|
||||
|
||||
preferences.edit()
|
||||
.putString(KEY_BIO_EMAIL, email)
|
||||
.putString(KEY_BIO_PASSWORD, password)
|
||||
.putString(KEY_BIO_TOKEN, biometricToken)
|
||||
.apply()
|
||||
}
|
||||
|
||||
fun loadBiometricCredentials(): Pair<String, String>? {
|
||||
fun loadBiometricToken(): Pair<String, String>? {
|
||||
val email = preferences.getString(KEY_BIO_EMAIL, null)
|
||||
val password = preferences.getString(KEY_BIO_PASSWORD, null)
|
||||
if (email.isNullOrBlank() || password.isNullOrBlank()) {
|
||||
val biometricToken = preferences.getString(KEY_BIO_TOKEN, null)
|
||||
if (email.isNullOrBlank() || biometricToken.isNullOrBlank()) {
|
||||
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 {
|
||||
private const val KEY_ACCESS = "access_token"
|
||||
private const val KEY_REFRESH = "refresh_token"
|
||||
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_CONFIGURED = "bio_configured"
|
||||
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) {
|
||||
private val repository = LinkedSignRepository(application.applicationContext)
|
||||
private var lastActivityAt = System.currentTimeMillis()
|
||||
|
||||
var uiState by mutableStateOf(MainUiState())
|
||||
private set
|
||||
@@ -52,7 +53,6 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
suspend fun login(email: String, password: String) {
|
||||
val session = repository.login(email, password)
|
||||
repository.syncBiometricCredentials(email, password)
|
||||
val (_, cache) = repository.loadLocalSnapshot()
|
||||
uiState = buildState(session, cache, loading = false)
|
||||
}
|
||||
@@ -63,8 +63,8 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
uiState = buildState(session, cache, loading = false)
|
||||
}
|
||||
|
||||
suspend fun register(email: String, password: String) {
|
||||
repository.register(email, password)
|
||||
suspend fun register(email: String, password: String, certPassword: String, certificateUri: Uri, certificateName: String) {
|
||||
repository.register(email, password, certPassword, certificateUri, certificateName)
|
||||
uiState = uiState.copy(emailForVerificationNotice = email)
|
||||
}
|
||||
|
||||
@@ -137,24 +137,52 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
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() {
|
||||
repository.logout()
|
||||
val (session, cache) = repository.loadLocalSnapshot()
|
||||
uiState = buildState(session, cache, loading = false)
|
||||
}
|
||||
|
||||
fun accessToken(): String? = repository.getAccessToken()
|
||||
suspend fun accessToken(): String? = repository.getFreshAccessToken()
|
||||
|
||||
fun enableBiometricAccess(email: String, password: String) {
|
||||
repository.enableBiometricAccess(email, password)
|
||||
suspend fun enableBiometricAccess() {
|
||||
repository.enableBiometricAccess()
|
||||
refreshState()
|
||||
}
|
||||
|
||||
fun declineBiometricAccess() {
|
||||
suspend fun declineBiometricAccess() {
|
||||
repository.declineBiometricAccess()
|
||||
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(
|
||||
session: SessionSnapshot,
|
||||
cache: CacheSnapshot,
|
||||
@@ -189,6 +217,15 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
uiState = buildState(session, cache, loading = false, syncing = uiState.syncing)
|
||||
}
|
||||
|
||||
private fun normalize(error: Exception): AppException =
|
||||
if (error is AppException) error else AppException(error.message ?: "Ocurrió un error inesperado.")
|
||||
private fun normalize(error: Exception): AppException {
|
||||
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"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
|
||||
<!-- Pen barrel (cuerpo redondeado) -->
|
||||
<path
|
||||
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
|
||||
android:fillColor="#4F46E5"
|
||||
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:fillColor="#4338CA"
|
||||
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
|
||||
android:fillColor="#0F172A"
|
||||
android:pathData="M38,76h32v4H38z" />
|
||||
android:fillColor="#FFFFFF"
|
||||
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>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<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" />
|
||||
</adaptive-icon>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<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" />
|
||||
</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"?>
|
||||
<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>
|
||||
|
||||
@@ -61,7 +61,7 @@ Valor actual:
|
||||
{
|
||||
"expo": {
|
||||
"extra": {
|
||||
"apiBaseUrl": "http://192.168.222.72:4000"
|
||||
"apiBaseUrl": "http://192.168.222.40:4000"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
"package": "net.linkedcorp.linkedsign"
|
||||
},
|
||||
"extra": {
|
||||
"apiBaseUrl": "http://192.168.222.72:4000"
|
||||
"apiBaseUrl": "http://192.168.222.40:4000"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import axios from 'axios';
|
||||
import { API_BASE_URL } from '../config/env';
|
||||
import { loadSession, saveSession, clearSession } from '../storage/session';
|
||||
import { notifySessionExpired } from '../utils/sessionEvents';
|
||||
|
||||
const client = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
@@ -38,6 +39,7 @@ client.interceptors.response.use(
|
||||
const session = await loadSession();
|
||||
if (!session.refreshToken) {
|
||||
await clearSession();
|
||||
notifySessionExpired();
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -68,6 +70,7 @@ client.interceptors.response.use(
|
||||
} catch (refreshError) {
|
||||
resolveQueue(refreshError, null);
|
||||
await clearSession();
|
||||
notifySessionExpired();
|
||||
throw refreshError;
|
||||
} finally {
|
||||
isRefreshing = false;
|
||||
|
||||
@@ -5,4 +5,4 @@ const extra = Constants.expoConfig?.extra || {};
|
||||
export const API_BASE_URL =
|
||||
process.env.EXPO_PUBLIC_API_BASE_URL ||
|
||||
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 { 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 { initLocalDb, saveProfileCache } from '../storage/localDb';
|
||||
import { clearSession, loadSession, saveSession } from '../storage/session';
|
||||
import { SESSION_EXPIRED_MESSAGE, setSessionExpiredHandler } from '../utils/sessionEvents';
|
||||
|
||||
const AuthContext = createContext(null);
|
||||
|
||||
@@ -11,6 +12,15 @@ export const AuthProvider = ({ children }) => {
|
||||
const [tokens, setTokens] = useState({ accessToken: null, refreshToken: null });
|
||||
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(() => {
|
||||
const bootstrap = async () => {
|
||||
initLocalDb();
|
||||
@@ -27,14 +37,56 @@ export const AuthProvider = ({ children }) => {
|
||||
setUser(data.data.user);
|
||||
saveProfileCache(data.data.user);
|
||||
} catch (error) {
|
||||
await clearSession();
|
||||
await clearLocalAuthState();
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
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 { data } = await apiLogin({ email, password });
|
||||
@@ -59,11 +111,9 @@ export const AuthProvider = ({ children }) => {
|
||||
} catch (error) {
|
||||
Alert.alert('Cerrar sesión', 'No se pudo notificar el cierre al backend. La sesión local se cerrará igual.');
|
||||
} finally {
|
||||
await clearSession();
|
||||
setUser(null);
|
||||
setTokens({ accessToken: null, refreshToken: null });
|
||||
await clearLocalAuthState();
|
||||
}
|
||||
}, []);
|
||||
}, [clearLocalAuthState]);
|
||||
|
||||
const requestPasswordChange = useCallback(async ({ 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=
|
||||
JWT_SECRET=change_me
|
||||
JWT_ACCESS_EXPIRES=15m
|
||||
JWT_REFRESH_EXPIRES=7d
|
||||
JWT_REFRESH_EXPIRES=15m
|
||||
JWT_BIOMETRIC_EXPIRES=180d
|
||||
STORAGE_PATH=/tmp/linkedsign_storage
|
||||
MAIL_HOST=smtp.gmail.com
|
||||
MAIL_PORT=587
|
||||
@@ -17,5 +18,12 @@ MAIL_TLS_REJECT_UNAUTHORIZED=true
|
||||
MAIL_USER=
|
||||
MAIL_PASS=
|
||||
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
|
||||
ANDROID_APK_PATH=
|
||||
|
||||
@@ -33,8 +33,8 @@ Variables importantes:
|
||||
|
||||
```env
|
||||
PORT=4000
|
||||
CORS_ORIGIN=http://192.168.222.72:5173
|
||||
FRONTEND_URL=http://192.168.222.72:5173
|
||||
CORS_ORIGIN=http://192.168.222.40:5173
|
||||
FRONTEND_URL=http://192.168.222.40:5173
|
||||
MAIL_HOST=
|
||||
MAIL_PORT=587
|
||||
MAIL_SECURE=false
|
||||
@@ -73,7 +73,7 @@ DB_NAME=
|
||||
Registra usuario y envía correo de validación.
|
||||
|
||||
```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" \
|
||||
-d '{
|
||||
"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.
|
||||
|
||||
```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" \
|
||||
-d '{
|
||||
"email": "usuario@correo.com",
|
||||
@@ -117,7 +117,7 @@ Devuelve usuario autenticado y preferencias visuales de firma.
|
||||
Sube un PDF original.
|
||||
|
||||
```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" \
|
||||
-F "file=@/ruta/documento.pdf"
|
||||
```
|
||||
@@ -137,7 +137,7 @@ Descarga un documento original.
|
||||
Sube un certificado `.p12` o `.pfx`.
|
||||
|
||||
```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" \
|
||||
-F "file=@/ruta/certificado.p12" \
|
||||
-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.
|
||||
|
||||
```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 "Content-Type: application/json" \
|
||||
-d '{
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "linkedsign-backend",
|
||||
"version": "0.1.0",
|
||||
"version": "1.2.35",
|
||||
"private": true,
|
||||
"description": "Initial backend for LinkedSign",
|
||||
"main": "src/server.js",
|
||||
|
||||
@@ -16,6 +16,7 @@ app.use(helmet());
|
||||
app.use(
|
||||
cors({
|
||||
origin: env.corsOrigin,
|
||||
credentials: true,
|
||||
})
|
||||
);
|
||||
app.use(morgan(env.nodeEnv === 'production' ? 'combined' : 'dev'));
|
||||
|
||||
@@ -3,6 +3,58 @@ const dotenv = require('dotenv');
|
||||
|
||||
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 = {
|
||||
port: Number(process.env.PORT) || 4000,
|
||||
nodeEnv: process.env.NODE_ENV || 'development',
|
||||
@@ -19,7 +71,10 @@ const env = {
|
||||
jwt: {
|
||||
secret: process.env.JWT_SECRET || 'change_me',
|
||||
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',
|
||||
@@ -39,7 +94,18 @@ const env = {
|
||||
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',
|
||||
refreshCookieName: process.env.REFRESH_COOKIE_NAME || 'linkedsign_refresh',
|
||||
androidApkPath:
|
||||
process.env.ANDROID_APK_PATH || path.resolve(process.cwd(), '..', 'android', 'apk', 'LinkedSign-debug.apk'),
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const path = require('path');
|
||||
const multer = require('multer');
|
||||
const { getDocumentPath, getCertificatePath } = require('../utils/storagePath');
|
||||
const { getDocumentPath, getCertificatePath, getTempUploadPath } = require('../utils/storagePath');
|
||||
|
||||
const documentStorage = multer.diskStorage({
|
||||
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({
|
||||
storage: documentStorage,
|
||||
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 authHeader = req.headers['authorization'];
|
||||
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);
|
||||
@@ -12,7 +17,12 @@ const authenticate = (req, res, next) => {
|
||||
req.user = { id: decoded.sub, email: decoded.email, role: decoded.role, name: decoded.name };
|
||||
return next();
|
||||
} 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 errorHandler = (err, req, res, next) => {
|
||||
@@ -14,6 +15,23 @@ const errorHandler = (err, req, res, next) => {
|
||||
? (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) {
|
||||
return res.status(statusCode).json({
|
||||
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 countSql = 'SELECT COUNT(*) AS total FROM audit_logs WHERE 1=1';
|
||||
const params = [];
|
||||
const countParams = [];
|
||||
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 ?';
|
||||
params.push(limit, offset);
|
||||
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 });
|
||||
};
|
||||
|
||||
const getEvents = async (filters) => {
|
||||
return auditRepository.getEvents(filters);
|
||||
const ADMIN_VISIBLE_TYPES = ['AUDIT_LOGS_PURGED'];
|
||||
|
||||
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 usersService = require('../users/users.service');
|
||||
const { sendSuccess } = require('../../utils/apiResponse');
|
||||
const env = require('../../config/env');
|
||||
|
||||
const getContext = (req) => ({
|
||||
ip: req.ip?.replace('::ffff:', '') || 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) => {
|
||||
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, {
|
||||
message: 'Registration successful. Please check your email to verify your account.',
|
||||
message: 'Registro exitoso. Revisa tu correo para verificar tu cuenta.',
|
||||
userId: result.userId,
|
||||
}, 201);
|
||||
} catch (err) {
|
||||
@@ -22,6 +62,7 @@ const register = async (req, res, next) => {
|
||||
const login = async (req, res, next) => {
|
||||
try {
|
||||
const result = await authService.login(req.body, getContext(req));
|
||||
res.cookie(env.refreshCookieName, result.refreshToken, getRefreshCookieOptions());
|
||||
return sendSuccess(res, result, 200);
|
||||
} catch (err) {
|
||||
return next(err);
|
||||
@@ -30,7 +71,18 @@ const login = async (req, res, next) => {
|
||||
|
||||
const refresh = async (req, res, next) => {
|
||||
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);
|
||||
} catch (err) {
|
||||
return next(err);
|
||||
@@ -39,8 +91,9 @@ const refresh = async (req, res, next) => {
|
||||
|
||||
const logout = async (req, res, next) => {
|
||||
try {
|
||||
await authService.logout(req.body.refreshToken);
|
||||
return sendSuccess(res, { message: 'Logged out.' }, 200);
|
||||
await authService.logout(readRefreshToken(req));
|
||||
res.clearCookie(env.refreshCookieName, getRefreshCookieOptions());
|
||||
return sendSuccess(res, { message: 'Sesión cerrada.' }, 200);
|
||||
} catch (err) {
|
||||
return next(err);
|
||||
}
|
||||
@@ -49,7 +102,7 @@ const logout = async (req, res, next) => {
|
||||
const changePassword = async (req, res, next) => {
|
||||
try {
|
||||
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) {
|
||||
return next(err);
|
||||
}
|
||||
@@ -58,7 +111,25 @@ const changePassword = async (req, res, next) => {
|
||||
const confirmPasswordChange = async (req, res, next) => {
|
||||
try {
|
||||
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) {
|
||||
return next(err);
|
||||
}
|
||||
@@ -67,7 +138,7 @@ const confirmPasswordChange = async (req, res, next) => {
|
||||
const verifyEmail = async (req, res, next) => {
|
||||
try {
|
||||
await authService.verifyEmail(req.params.token);
|
||||
return sendSuccess(res, { message: 'Email verified successfully.' }, 200);
|
||||
return sendSuccess(res, { message: 'Correo verificado correctamente.' }, 200);
|
||||
} catch (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 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 [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]
|
||||
);
|
||||
return rows[0] || null;
|
||||
@@ -18,12 +62,25 @@ const findById = async (id) => {
|
||||
|
||||
const findAuthUserById = async (id) => {
|
||||
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]
|
||||
);
|
||||
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 [result] = await db.execute(
|
||||
`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 hashedToken = hashRefreshToken(token);
|
||||
await db.execute(
|
||||
'INSERT INTO refresh_tokens (user_id, token, expires_at) VALUES (?, ?, ?)',
|
||||
[userId, token, expiresAt]
|
||||
[userId, hashedToken, expiresAt]
|
||||
);
|
||||
};
|
||||
|
||||
const findRefreshToken = async (token) => {
|
||||
const hashedToken = hashRefreshToken(token);
|
||||
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()',
|
||||
[token]
|
||||
'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()',
|
||||
[hashedToken, token]
|
||||
);
|
||||
return rows[0] || null;
|
||||
};
|
||||
|
||||
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) => {
|
||||
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) => {
|
||||
await db.execute(
|
||||
'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 = {
|
||||
ensureSchema,
|
||||
findByEmail,
|
||||
findById,
|
||||
findAuthUserById,
|
||||
findByUnlockToken,
|
||||
createUser,
|
||||
verifyEmail,
|
||||
updateVerificationToken,
|
||||
recordFailedLoginAttempt,
|
||||
setAccountLocked,
|
||||
resetFailedLoginAttempts,
|
||||
clearAccountLock,
|
||||
updateUnlockToken,
|
||||
saveRefreshToken,
|
||||
findRefreshToken,
|
||||
deleteRefreshToken,
|
||||
deleteUserRefreshTokens,
|
||||
saveBiometricToken,
|
||||
findBiometricToken,
|
||||
deleteBiometricToken,
|
||||
deleteUserBiometricTokens,
|
||||
setResetToken,
|
||||
findByResetToken,
|
||||
updatePassword,
|
||||
deleteUser,
|
||||
};
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
const express = require('express');
|
||||
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 { uploadTempCertificate } = require('../../config/multer');
|
||||
|
||||
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('/refresh', validate(refreshSchema), authController.refresh);
|
||||
router.post('/biometric-login', validate(biometricTokenSchema), authController.biometricLogin);
|
||||
router.post('/logout', authController.logout);
|
||||
router.get('/verify/:token', authController.verifyEmail);
|
||||
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.post('/biometric-token', authenticate, authController.issueBiometricToken);
|
||||
router.delete('/biometric-token', authenticate, authController.revokeBiometricToken);
|
||||
router.patch('/change-password', authenticate, validate(changePasswordSchema), authController.changePassword);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -1,13 +1,22 @@
|
||||
const bcrypt = require('bcrypt');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const crypto = require('crypto');
|
||||
const authRepository = require('./auth.repository');
|
||||
const passwordChangeRepository = require('./password-change.repository');
|
||||
const auditService = require('../audit/audit.service');
|
||||
const usersService = require('../users/users.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 fs = require('fs/promises');
|
||||
const path = require('path');
|
||||
const env = require('../../config/env');
|
||||
|
||||
const SALT_ROUNDS = 12;
|
||||
const MAX_FAILED_LOGIN_ATTEMPTS = 3;
|
||||
const createBiometricToken = () => crypto.randomBytes(48).toString('hex');
|
||||
|
||||
const buildDisplayNameFromEmail = (email) => {
|
||||
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);
|
||||
if (existing) {
|
||||
const err = new Error('Email already registered.');
|
||||
@@ -57,17 +84,56 @@ const register = async ({ email, password }) => {
|
||||
verificationToken,
|
||||
verificationTokenExpires,
|
||||
});
|
||||
let mailResult;
|
||||
|
||||
let uploadedCertificate = null;
|
||||
let legalDocuments = [];
|
||||
|
||||
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,
|
||||
email,
|
||||
verificationToken,
|
||||
attachments,
|
||||
});
|
||||
} 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.');
|
||||
err.statusCode = 503;
|
||||
err.code = 'MAIL_SEND_FAILED';
|
||||
await authRepository.deleteUser(userId);
|
||||
try {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -77,10 +143,16 @@ const register = async ({ email, password }) => {
|
||||
email,
|
||||
metadata: {
|
||||
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 = {}) => {
|
||||
@@ -88,25 +160,72 @@ const login = async ({ email, password }, context = {}) => {
|
||||
|
||||
if (!user || !user.is_active) {
|
||||
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;
|
||||
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);
|
||||
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 });
|
||||
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;
|
||||
throw err;
|
||||
}
|
||||
|
||||
await authRepository.resetFailedLoginAttempts(user.id);
|
||||
|
||||
if (!user.is_verified) {
|
||||
let verificationUrl = null;
|
||||
try {
|
||||
({ verificationUrl } = await issueVerificationEmail(user));
|
||||
} 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.code = 'MAIL_SEND_FAILED';
|
||||
throw err;
|
||||
@@ -119,7 +238,7 @@ const login = async ({ email, password }, context = {}) => {
|
||||
userAgent: context.userAgent,
|
||||
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.code = 'EMAIL_NOT_VERIFIED';
|
||||
err.data = { verificationUrl };
|
||||
@@ -130,7 +249,7 @@ const login = async ({ email, password }, context = {}) => {
|
||||
const accessToken = signAccess(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 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) => {
|
||||
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);
|
||||
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;
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (!record.is_active) {
|
||||
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;
|
||||
throw err;
|
||||
}
|
||||
@@ -170,16 +296,86 @@ const refresh = async (token) => {
|
||||
const payload = { sub: record.uid, email: record.email, role: record.role };
|
||||
const accessToken = signAccess(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);
|
||||
|
||||
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) => {
|
||||
if (!token) return;
|
||||
await authRepository.deleteRefreshToken(token);
|
||||
};
|
||||
|
||||
const revokeBiometricToken = async (userId) => {
|
||||
await authRepository.deleteUserBiometricTokens(userId);
|
||||
};
|
||||
|
||||
const changePassword = async (userId, { currentPassword, newPassword }, context = {}) => {
|
||||
const user = await authRepository.findAuthUserById(userId);
|
||||
if (!user || !user.is_active) {
|
||||
@@ -230,6 +426,76 @@ const changePassword = async (userId, { currentPassword, newPassword }, context
|
||||
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 request = await passwordChangeRepository.findRequest(token);
|
||||
if (!request) {
|
||||
@@ -254,6 +520,11 @@ const confirmPasswordChange = async (token) => {
|
||||
}
|
||||
|
||||
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 auditService.logEvent({
|
||||
@@ -283,4 +554,17 @@ const verifyEmail = async (token) => {
|
||||
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;
|
||||
};
|
||||
|
||||
const registerSchema = Joi.object({
|
||||
email: Joi.string().email().required(),
|
||||
password: Joi.string().max(100).required().custom(isStrongPassword),
|
||||
});
|
||||
const passwordRuleCheckers = [
|
||||
{ test: (value) => value.length >= 8, label: 'al menos 8 caracteres' },
|
||||
{ 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({
|
||||
email: Joi.string().email().required(),
|
||||
@@ -28,7 +35,11 @@ const loginSchema = 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({
|
||||
@@ -36,6 +47,47 @@ const changePasswordSchema = Joi.object({
|
||||
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 { error } = schema.validate(req.body, { abortEarly: false });
|
||||
if (error) {
|
||||
@@ -45,4 +97,12 @@ const validate = (schema) => (req, res, 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;
|
||||
};
|
||||
|
||||
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) => {
|
||||
await db.execute('UPDATE certificates SET is_active = 0 WHERE id = ?', [id]);
|
||||
};
|
||||
@@ -70,6 +78,7 @@ module.exports = {
|
||||
create,
|
||||
findByUser,
|
||||
findById,
|
||||
findLatestActiveByUser,
|
||||
deactivate,
|
||||
remove,
|
||||
saveChainStatus,
|
||||
|
||||
@@ -126,6 +126,8 @@ const getOne = async (id, userId) => {
|
||||
};
|
||||
};
|
||||
|
||||
const getLatestActiveForUser = async (userId) => certificatesRepository.findLatestActiveByUser(userId);
|
||||
|
||||
const remove = async (id, userId, context = {}) => {
|
||||
const cert = await certificatesRepository.findById(id);
|
||||
if (!cert) throw makeError('Certificate not found.', 404);
|
||||
@@ -165,4 +167,12 @@ const loadForSigning = async (certId, userId, password) => {
|
||||
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;
|
||||
};
|
||||
|
||||
const sendMail = async ({ to, subject, html, text }) => {
|
||||
const sendMail = async ({ to, subject, html, text, attachments }) => {
|
||||
const activeTransporter = getTransporter();
|
||||
const from = env.mail.from.includes('<')
|
||||
? env.mail.from
|
||||
: `LinkedSign <${env.mail.from}>`;
|
||||
const info = await activeTransporter.sendMail({
|
||||
from: env.mail.from,
|
||||
from,
|
||||
replyTo: env.mail.from,
|
||||
to,
|
||||
subject,
|
||||
html,
|
||||
text,
|
||||
attachments,
|
||||
});
|
||||
|
||||
return info;
|
||||
|
||||
@@ -1,38 +1,82 @@
|
||||
const env = require('../../config/env');
|
||||
const mailRepository = require('./mail.repository');
|
||||
|
||||
const sendVerificationEmail = async ({ name, email, verificationToken }) => {
|
||||
const verificationUrl = `${env.frontendUrl.replace(/\/$/, '')}/auth/verify-email/${verificationToken}`;
|
||||
const subject = 'Confirma tu cuenta de LinkedSign';
|
||||
const escapeHtml = (value) => String(value ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
|
||||
const buildBaseUrl = () => env.frontendUrl.replace(/\/$/, '');
|
||||
|
||||
const supportEmail = () => env.mail.from;
|
||||
|
||||
const renderEmailLayout = ({ preheader, title, intro, bodyHtml, ctaLabel, ctaUrl, footerNote }) => `
|
||||
<div style="display:none;max-height:0;overflow:hidden;opacity:0;color:transparent;">
|
||||
${escapeHtml(preheader)}
|
||||
</div>
|
||||
<div style="margin:0;padding:24px;background:#f3f6fb;font-family:Arial,sans-serif;color:#14213d;">
|
||||
<div style="max-width:620px;margin:0 auto;background:#ffffff;border:1px solid #d9e2f0;border-radius:14px;overflow:hidden;">
|
||||
<div style="padding:18px 24px;border-bottom:1px solid #e5ecf5;background:#f8fbff;">
|
||||
<div style="font-size:18px;font-weight:700;color:#1d4ed8;">LinkedSign</div>
|
||||
<div style="font-size:12px;color:#64748b;margin-top:4px;">Notificación transaccional del sistema</div>
|
||||
</div>
|
||||
<div style="padding:24px;">
|
||||
<h2 style="margin:0 0 14px;font-size:22px;line-height:1.3;color:#0f172a;">${escapeHtml(title)}</h2>
|
||||
<p style="margin:0 0 16px;font-size:14px;line-height:1.6;color:#334155;">${intro}</p>
|
||||
${bodyHtml}
|
||||
<div style="margin:24px 0 18px;">
|
||||
<a href="${ctaUrl}" style="background:#2563eb;color:#ffffff;text-decoration:none;padding:12px 18px;border-radius:10px;display:inline-block;font-size:14px;font-weight:700;">
|
||||
${escapeHtml(ctaLabel)}
|
||||
</a>
|
||||
</div>
|
||||
<p style="margin:0 0 10px;font-size:12px;line-height:1.6;color:#64748b;">
|
||||
Si el botón no funciona, copia y pega este enlace en tu navegador:
|
||||
</p>
|
||||
<p style="margin:0 0 18px;font-size:12px;word-break:break-all;">
|
||||
<a href="${ctaUrl}" style="color:#2563eb;text-decoration:none;">${ctaUrl}</a>
|
||||
</p>
|
||||
${footerNote ? `<p style="margin:0;font-size:12px;line-height:1.6;color:#64748b;">${footerNote}</p>` : ''}
|
||||
</div>
|
||||
<div style="padding:14px 24px;border-top:1px solid #e5ecf5;background:#fafcff;font-size:11px;line-height:1.6;color:#64748b;">
|
||||
Mensaje enviado por LinkedSign desde ${escapeHtml(buildBaseUrl())}. Si necesitas ayuda, responde a ${escapeHtml(supportEmail())}.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const sendVerificationEmail = async ({ name, email, verificationToken, attachments = [] }) => {
|
||||
const verificationUrl = `${buildBaseUrl()}/auth/verify-email/${verificationToken}`;
|
||||
const subject = 'Verifica tu cuenta de LinkedSign';
|
||||
const text = [
|
||||
`Hola ${name},`,
|
||||
'',
|
||||
'Confirma tu cuenta de LinkedSign usando este enlace:',
|
||||
'Para activar tu cuenta de LinkedSign, verifica tu correo usando este enlace:',
|
||||
verificationUrl,
|
||||
'',
|
||||
attachments.length > 0 ? 'Adjuntamos una copia de los documentos legales firmados durante tu registro.' : '',
|
||||
'',
|
||||
'Si no solicitaste esta cuenta, puedes ignorar este mensaje.',
|
||||
].join('\n');
|
||||
const html = `
|
||||
<div style="font-family: Arial, sans-serif; line-height: 1.5; color: #111827;">
|
||||
<h2 style="margin-bottom: 12px;">Confirma tu cuenta de LinkedSign</h2>
|
||||
<p>Hola ${name},</p>
|
||||
<p>Para activar tu cuenta, confirma tu correo electrónico haciendo clic en el siguiente botón:</p>
|
||||
<p style="margin: 24px 0;">
|
||||
<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
|
||||
</a>
|
||||
</p>
|
||||
<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>
|
||||
`;
|
||||
].filter(Boolean).join('\n');
|
||||
const html = renderEmailLayout({
|
||||
preheader: 'Verifica tu correo para activar tu cuenta de LinkedSign.',
|
||||
title: 'Verifica tu cuenta',
|
||||
intro: `Hola ${escapeHtml(name)}. Para activar tu cuenta de LinkedSign, verifica tu correo electrónico.`,
|
||||
bodyHtml: attachments.length > 0
|
||||
? '<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>'
|
||||
: '',
|
||||
ctaLabel: 'Verificar cuenta',
|
||||
ctaUrl: verificationUrl,
|
||||
footerNote: 'Si no solicitaste esta cuenta, puedes ignorar este mensaje.',
|
||||
});
|
||||
|
||||
const info = await mailRepository.sendMail({
|
||||
to: email,
|
||||
subject,
|
||||
html,
|
||||
text,
|
||||
attachments,
|
||||
});
|
||||
|
||||
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.',
|
||||
].join('\n');
|
||||
const html = `
|
||||
<div style="font-family: Arial, sans-serif; line-height: 1.5; color: #111827;">
|
||||
<h2 style="margin-bottom: 12px;">Contraseña actualizada</h2>
|
||||
<p>Hola ${name},</p>
|
||||
<p>Te confirmamos que la contraseña de tu cuenta de LinkedSign fue actualizada.</p>
|
||||
<p><strong>Fecha:</strong> ${formattedDate}</p>
|
||||
<p>Si no realizaste este cambio, contacta soporte inmediatamente.</p>
|
||||
<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;">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>
|
||||
`;
|
||||
|
||||
@@ -80,7 +131,7 @@ const sendPasswordChangedEmail = async ({ name, email, changedAt }) => {
|
||||
};
|
||||
|
||||
const sendPasswordChangeConfirmationEmail = async ({ name, email, confirmationToken }) => {
|
||||
const confirmationUrl = `${env.frontendUrl.replace(/\/$/, '')}/auth/confirm-password-change/${confirmationToken}`;
|
||||
const confirmationUrl = `${buildBaseUrl()}/auth/confirm-password-change/${confirmationToken}`;
|
||||
const subject = 'Confirma el cambio de contraseña de LinkedSign';
|
||||
const text = [
|
||||
`Hola ${name},`,
|
||||
@@ -91,22 +142,15 @@ const sendPasswordChangeConfirmationEmail = async ({ name, email, confirmationTo
|
||||
'',
|
||||
'Si no realizaste esta solicitud, ignora este mensaje.',
|
||||
].join('\n');
|
||||
const html = `
|
||||
<div style="font-family: Arial, sans-serif; line-height: 1.5; color: #111827;">
|
||||
<h2 style="margin-bottom: 12px;">Confirma el cambio de contraseña</h2>
|
||||
<p>Hola ${name},</p>
|
||||
<p>Recibimos una solicitud para cambiar la contraseña de tu cuenta de LinkedSign.</p>
|
||||
<p>Para aplicar el cambio, confirma la solicitud usando este botón:</p>
|
||||
<p style="margin: 24px 0;">
|
||||
<a href="${confirmationUrl}" style="background: #4f46e5; color: #ffffff; text-decoration: none; padding: 12px 18px; border-radius: 8px; display: inline-block; font-weight: 600;">
|
||||
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 html = renderEmailLayout({
|
||||
preheader: 'Confirma el cambio de contraseña de tu cuenta de LinkedSign.',
|
||||
title: 'Confirma el cambio de contraseña',
|
||||
intro: `Hola ${escapeHtml(name)}. Recibimos una solicitud para cambiar la contraseña de tu cuenta de LinkedSign.`,
|
||||
bodyHtml: '<p style="margin:0 0 14px;font-size:14px;line-height:1.6;color:#334155;">Para aplicar el cambio, confirma esta solicitud.</p>',
|
||||
ctaLabel: 'Confirmar cambio',
|
||||
ctaUrl: confirmationUrl,
|
||||
footerNote: 'Si no realizaste esta solicitud, puedes ignorar este mensaje.',
|
||||
});
|
||||
|
||||
const info = await mailRepository.sendMail({
|
||||
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 = {
|
||||
sendVerificationEmail,
|
||||
sendPasswordChangedEmail,
|
||||
sendPasswordChangeConfirmationEmail,
|
||||
sendAccountLockedEmail,
|
||||
sendExternalSigningInvitationEmail,
|
||||
sendExternalSigningOwnerNotificationEmail,
|
||||
};
|
||||
|
||||
@@ -8,18 +8,21 @@ const getCtx = (req) => ({
|
||||
userName: req.user?.name || req.user?.email,
|
||||
userEmail: req.user?.email || 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) => {
|
||||
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 certId = Number(certificateId);
|
||||
if (!docId || !certId || isNaN(docId) || isNaN(certId)) {
|
||||
const e = new Error('documentId and certificateId are required.'); e.statusCode = 400; throw e;
|
||||
}
|
||||
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,
|
||||
getCtx(req)
|
||||
);
|
||||
|
||||
@@ -71,6 +71,27 @@ const findByUser = async (userId) => {
|
||||
return rows;
|
||||
};
|
||||
|
||||
const findLatestByDocumentAndUser = async (documentId, userId) => {
|
||||
const [rows] = await db.execute(
|
||||
`SELECT *
|
||||
FROM signatures
|
||||
WHERE document_id = ? AND user_id = ?
|
||||
ORDER BY signed_at DESC, id DESC
|
||||
LIMIT 1`,
|
||||
[documentId, userId]
|
||||
);
|
||||
return rows[0] || null;
|
||||
};
|
||||
|
||||
const updateSignedArtifact = async (id, signedFilename, storagePath) => {
|
||||
await db.execute(
|
||||
`UPDATE signatures
|
||||
SET signed_filename = ?, storage_path = ?
|
||||
WHERE id = ?`,
|
||||
[signedFilename, storagePath, id]
|
||||
);
|
||||
};
|
||||
|
||||
const saveVerificationRecord = async (code, data) => {
|
||||
const current = await readVerificationMap();
|
||||
current[code] = data;
|
||||
@@ -107,6 +128,8 @@ module.exports = {
|
||||
findByDocument,
|
||||
findById,
|
||||
findByUser,
|
||||
findLatestByDocumentAndUser,
|
||||
updateSignedArtifact,
|
||||
saveVerificationRecord,
|
||||
findVerificationRecord,
|
||||
deleteVerificationRecordBySignatureId,
|
||||
|
||||
@@ -5,11 +5,12 @@ const signaturesRepository = require('./signatures.repository');
|
||||
const documentsRepository = require('../documents/documents.repository');
|
||||
const certificatesRepository = require('../certificates/certificates.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 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);
|
||||
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; }
|
||||
@@ -32,6 +33,12 @@ const sign = async ({ documentId, certificateId, certPassword, reason, location,
|
||||
const verificationCode = uuidv4();
|
||||
const frontendBaseUrl = (context.frontendUrl || env.frontendUrl || '').replace(/\/$/, '');
|
||||
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;
|
||||
try {
|
||||
@@ -44,12 +51,15 @@ const sign = async ({ documentId, certificateId, certPassword, reason, location,
|
||||
location: location || '',
|
||||
page: page || 0,
|
||||
rect: rect || null,
|
||||
signatureStyle: signatureStyle || 'classic',
|
||||
qrPosition: qrPosition || 'left',
|
||||
qrBackground: qrBackground || 'transparent',
|
||||
qrBorder: qrBorder || 'none',
|
||||
signatureImageBase64: signatureImageBase64 || null,
|
||||
signatureStyle: effectiveSignatureStyle,
|
||||
qrPosition: effectiveQrPosition,
|
||||
qrBackground: effectiveQrBackground,
|
||||
qrBorder: effectiveQrBorder,
|
||||
signatureImageBase64: effectiveSignatureImageBase64,
|
||||
verificationUrl,
|
||||
clientLocale: clientLocale || context.clientLocale || null,
|
||||
clientTimeZone: clientTimeZone || context.clientTimeZone || null,
|
||||
clientCountry: clientCountry || context.clientCountry || null,
|
||||
}));
|
||||
} catch (signErr) {
|
||||
const msg = signErr.message || '';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
const fs = require('fs/promises');
|
||||
const path = require('path');
|
||||
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 { SignPdf } = require('@signpdf/signpdf');
|
||||
const { P12Signer } = require('@signpdf/signer-p12');
|
||||
@@ -33,11 +33,104 @@ function truncate(text, font, size, maxWidth) {
|
||||
}
|
||||
|
||||
function getSignatureWidth(style, signerName = '') {
|
||||
if (style !== 'qr') return 200;
|
||||
if (style === 'qr' || style === 'firmaec') {
|
||||
const normalizedName = String(signerName).trim();
|
||||
const calculatedWidth = 78 + (normalizedName.length * 4.9);
|
||||
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) ──────────────────────────────────────
|
||||
async function drawStyleClassic(pdfDoc, page, sx, sy, sw, sh, { signerName, reason, dateStr, timeStr, location }) {
|
||||
@@ -212,6 +305,68 @@ async function drawStyleImage(pdfDoc, page, sx, sy, sw, sh, { signerName, reason
|
||||
if (location) page.drawText(truncate(location, f2, 6, textW), { x: textX, y: sy + sh - 59, size: 6, font: f2, color: C.lightGray });
|
||||
}
|
||||
|
||||
// ── 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 ─────────────────────────────────────────────────────────
|
||||
const signPdf = async ({
|
||||
pdfPath, p12Buffer, p12Password,
|
||||
@@ -222,6 +377,10 @@ const signPdf = async ({
|
||||
qrBackground = 'transparent',
|
||||
qrBorder = 'none',
|
||||
verificationUrl = null,
|
||||
clientLocale = null,
|
||||
clientTimeZone = null,
|
||||
outputDir = null,
|
||||
outputBaseName = null,
|
||||
}) => {
|
||||
const pdfBytes = await fs.readFile(pdfPath);
|
||||
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 now = new Date();
|
||||
const dateStr = now.toLocaleDateString('es-EC', { day: '2-digit', month: '2-digit', year: 'numeric' });
|
||||
const timeStr = now.toLocaleTimeString('es-EC', { hour: '2-digit', minute: '2-digit', hour12: true });
|
||||
const { dateStr, timeStr } = buildLocalizedSignatureTimestamp({
|
||||
locale: clientLocale,
|
||||
timeZone: clientTimeZone,
|
||||
});
|
||||
|
||||
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);
|
||||
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 === 'firmaec') await drawStyleFirmaEC(pdfDoc, tp, sx, sy, sw, sh, info);
|
||||
else await drawStyleClassic(pdfDoc, tp, sx, sy, sw, sh, info);
|
||||
|
||||
// ── Save page with visual stamp ───────────────────────────────────
|
||||
@@ -274,16 +435,24 @@ const signPdf = async ({
|
||||
|
||||
// ── Add cryptographic signature placeholder ───────────────────────
|
||||
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({
|
||||
pdfDoc: pdfDocForSign,
|
||||
pdfPage: placeholderPage,
|
||||
reason: reason || 'Firma digital',
|
||||
contactInfo: signerName,
|
||||
name: signerName,
|
||||
location: location || '',
|
||||
signatureLength: 32768,
|
||||
rect: [sx, sy, sx + sw, sy + sh],
|
||||
pageIndex: Math.min(page, pdfDocForSign.getPageCount() - 1),
|
||||
widgetRect,
|
||||
});
|
||||
if (!useInvisibleWidget) {
|
||||
hideSignatureWidgetBorder(pdfDocForSign, placeholderPageIndex);
|
||||
}
|
||||
|
||||
const pdfWithPlaceholder = Buffer.from(await pdfDocForSign.save({ useObjectStreams: false }));
|
||||
|
||||
@@ -293,9 +462,9 @@ const signPdf = async ({
|
||||
const signedBuf = await signInstance.sign(pdfWithPlaceholder, signer);
|
||||
|
||||
// ── Save ──────────────────────────────────────────────────────────
|
||||
const destDir = await getSignedDocumentPath();
|
||||
const destDir = outputDir || await getSignedDocumentPath();
|
||||
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 year = String(nowForFilename.getFullYear());
|
||||
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 minutes = String(nowForFilename.getMinutes()).padStart(2, '0');
|
||||
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);
|
||||
await fs.writeFile(signedPath, signedBuf);
|
||||
|
||||
|
||||
@@ -1,44 +1,36 @@
|
||||
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_BACKGROUNDS = ['transparent', 'soft'];
|
||||
const ALLOWED_QR_BORDERS = ['none', 'line'];
|
||||
const LEGACY_SIGNATURE_STYLE_MAP = {
|
||||
image: 'qr',
|
||||
firmaecuador: 'firmaec',
|
||||
'firma ec': 'firmaec',
|
||||
};
|
||||
|
||||
const getSignaturePreferences = async (userId) => {
|
||||
return usersRepository.getSignaturePreferences(userId);
|
||||
};
|
||||
|
||||
const updateSignaturePreferences = async (userId, { signatureStyle, qrPosition, qrBackground, qrBorder, signatureImageBase64 }) => {
|
||||
if (!ALLOWED_SIGNATURE_STYLES.includes(signatureStyle)) {
|
||||
const error = new Error('Invalid signature style.');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
const rawSignatureStyle = String(signatureStyle || '').trim().toLowerCase();
|
||||
let normalizedSignatureStyle = LEGACY_SIGNATURE_STYLE_MAP[rawSignatureStyle] || rawSignatureStyle || 'qr';
|
||||
if (!ALLOWED_SIGNATURE_STYLES.includes(normalizedSignatureStyle)) {
|
||||
normalizedSignatureStyle = rawSignatureStyle.includes('firma') ? 'firmaec' : 'qr';
|
||||
}
|
||||
|
||||
if (!ALLOWED_QR_POSITIONS.includes(qrPosition)) {
|
||||
const error = new Error('Invalid QR position.');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
// Preferences should be resilient to stale clients or partially populated forms.
|
||||
const resolvedQrPosition = ALLOWED_QR_POSITIONS.includes(qrPosition) ? qrPosition : 'left';
|
||||
const resolvedQrBackground = ALLOWED_QR_BACKGROUNDS.includes(qrBackground) ? qrBackground : 'transparent';
|
||||
const resolvedQrBorder = ALLOWED_QR_BORDERS.includes(qrBorder) ? qrBorder : 'none';
|
||||
|
||||
return usersRepository.setSignaturePreferences(userId, {
|
||||
signatureStyle,
|
||||
qrPosition,
|
||||
qrBackground,
|
||||
qrBorder,
|
||||
signatureStyle: normalizedSignatureStyle,
|
||||
qrPosition: resolvedQrPosition,
|
||||
qrBackground: resolvedQrBackground,
|
||||
qrBorder: resolvedQrBorder,
|
||||
signatureImageBase64: signatureImageBase64 || null,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,18 +1,24 @@
|
||||
const express = require('express');
|
||||
const authRoutes = require('../modules/auth/auth.routes');
|
||||
const auditRoutes = require('../modules/audit/audit.routes');
|
||||
const certificatesRoutes = require('../modules/certificates/certificates.routes');
|
||||
const downloadsRoutes = require('../modules/downloads/downloads.routes');
|
||||
const documentsRoutes = require('../modules/documents/documents.routes');
|
||||
const invitationsRoutes = require('../modules/invitations/invitations.routes');
|
||||
const signaturesRoutes = require('../modules/signatures/signatures.routes');
|
||||
const legalRoutes = require('../modules/legal/legal.routes');
|
||||
const usersRoutes = require('../modules/users/users.routes');
|
||||
const healthRoutes = require('../modules/health/health.routes');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.use('/auth', authRoutes);
|
||||
router.use('/audit', auditRoutes);
|
||||
router.use('/certificates', certificatesRoutes);
|
||||
router.use('/downloads', downloadsRoutes);
|
||||
router.use('/documents', documentsRoutes);
|
||||
router.use('/invitations', invitationsRoutes);
|
||||
router.use('/legal', legalRoutes);
|
||||
router.use('/signatures', signaturesRoutes);
|
||||
router.use('/users', usersRoutes);
|
||||
router.use('/health', healthRoutes);
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||
const app = require('./app');
|
||||
const env = require('./config/env');
|
||||
const authRepository = require('./modules/auth/auth.repository');
|
||||
const invitationsRepository = require('./modules/invitations/invitations.repository');
|
||||
|
||||
const start = async () => {
|
||||
await authRepository.ensureSchema();
|
||||
await invitationsRepository.ensureSchema();
|
||||
|
||||
app.listen(env.port, () => {
|
||||
console.log(`LinkedSign backend running on port ${env.port}`);
|
||||
});
|
||||
};
|
||||
|
||||
start().catch((error) => {
|
||||
console.error('Failed to start backend:', error.message);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
@@ -26,4 +26,22 @@ const getCertificatePath = async (userId) => {
|
||||
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">
|
||||
<head>
|
||||
<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" />
|
||||
<title>frontend</title>
|
||||
<title>LinkedSign</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"version": "1.2.35",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"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 VerifyEmailPage from './pages/auth/VerifyEmailPage';
|
||||
import ConfirmPasswordChangePage from './pages/auth/ConfirmPasswordChangePage';
|
||||
import UnlockAccountPage from './pages/auth/UnlockAccountPage';
|
||||
import DashboardPage from './pages/dashboard/DashboardPage';
|
||||
import DocumentsPage from './pages/documents/DocumentsPage';
|
||||
import CertificatesPage from './pages/certificates/CertificatesPage';
|
||||
import SignatureStylePage from './pages/signature-style/SignatureStylePage';
|
||||
import SignaturesPage from './pages/signatures/SignaturesPage';
|
||||
import VerifySignaturePage from './pages/verify/VerifySignaturePage';
|
||||
import LegalPage from './pages/legal/LegalPage';
|
||||
import AuditPage from './pages/audit/AuditPage';
|
||||
import GuestInvitationPage from './pages/invitations/GuestInvitationPage';
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
@@ -24,6 +28,8 @@ export default function App() {
|
||||
<Route path="/register" element={<RegisterPage />} />
|
||||
<Route path="/auth/verify-email/:token" element={<VerifyEmailPage />} />
|
||||
<Route path="/auth/confirm-password-change/:token" element={<ConfirmPasswordChangePage />} />
|
||||
<Route path="/auth/unlock-account/:token" element={<UnlockAccountPage />} />
|
||||
<Route path="/guest-sign/:token" element={<GuestInvitationPage />} />
|
||||
<Route path="/verify/:code" element={<VerifySignaturePage />} />
|
||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||
<Route
|
||||
@@ -46,6 +52,14 @@ export default function App() {
|
||||
path="/signatures"
|
||||
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 />} />
|
||||
</Routes>
|
||||
</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';
|
||||
|
||||
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 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 verifyEmail = (token) => client.get(`/auth/verify/${token}`);
|
||||
export const changePassword = (data) => client.patch('/auth/change-password', data);
|
||||
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 {
|
||||
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({
|
||||
baseURL: API_URL,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
client.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('accessToken');
|
||||
const token = getAccessToken();
|
||||
if (token) config.headers.Authorization = `Bearer ${token}`;
|
||||
return config;
|
||||
});
|
||||
@@ -17,24 +25,30 @@ client.interceptors.response.use(
|
||||
(res) => res,
|
||||
async (error) => {
|
||||
const original = error.config;
|
||||
if (!original) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
if (error.response?.status === 401 && !original._retry) {
|
||||
original._retry = true;
|
||||
const refreshToken = localStorage.getItem('refreshToken');
|
||||
if (refreshToken) {
|
||||
const hadAccessToken = Boolean(getAccessToken());
|
||||
if (hadAccessToken) {
|
||||
try {
|
||||
const { data } = await axios.post(`${API_URL}/auth/refresh`, { refreshToken });
|
||||
const { accessToken, refreshToken: newRefresh } = data.data;
|
||||
localStorage.setItem('accessToken', accessToken);
|
||||
localStorage.setItem('refreshToken', newRefresh);
|
||||
const { data } = await axios.post(`${API_URL}/auth/refresh`, {}, { withCredentials: true });
|
||||
const { accessToken } = data.data;
|
||||
setAccessToken(accessToken);
|
||||
original.headers.Authorization = `Bearer ${accessToken}`;
|
||||
return client(original);
|
||||
} catch {
|
||||
localStorage.removeItem('accessToken');
|
||||
localStorage.removeItem('refreshToken');
|
||||
window.location.href = '/login';
|
||||
expireSession();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (error.response?.status === 401) {
|
||||
clearAccessToken();
|
||||
}
|
||||
|
||||
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 { 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 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 { Link, useNavigate, useLocation } from 'react-router-dom';
|
||||
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 packageInfo from '../../../package.json';
|
||||
|
||||
const navSections = [
|
||||
{
|
||||
label: 'Principal',
|
||||
items: [
|
||||
{ to: '/dashboard', icon: LayoutDashboard, label: 'Dashboard' },
|
||||
{ to: '/documents', icon: FileText, label: 'Mis documentos' },
|
||||
{ to: '/signatures', icon: PenTool, label: 'Firmar ahora' },
|
||||
{ to: '/documents', icon: FileText, label: 'Mis documentos' },
|
||||
{ to: '/certificates', icon: Users, label: 'Firmantes' },
|
||||
],
|
||||
},
|
||||
@@ -18,6 +19,7 @@ const navSections = [
|
||||
label: 'Sistema',
|
||||
items: [
|
||||
{ to: '/signature-style', icon: Settings, label: 'Configuración' },
|
||||
{ to: '/audit', icon: ScrollText, label: 'Auditoría' },
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -28,6 +30,8 @@ const topbarTitles = {
|
||||
'/certificates': 'Firmantes',
|
||||
'/signature-style': 'Configuración',
|
||||
'/signatures': 'Firmar ahora',
|
||||
'/legal': 'Legal',
|
||||
'/audit': 'Auditoría',
|
||||
};
|
||||
|
||||
export default function Layout({ children }) {
|
||||
@@ -80,11 +84,11 @@ export default function Layout({ children }) {
|
||||
<aside className={`sidebar ${mobileMenuOpen ? 'sidebar--mobile-open' : ''}`}>
|
||||
<div className="sidebar-brand">
|
||||
<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 className="sidebar-brand-copy">
|
||||
<span>FirmaDoc</span>
|
||||
<small>Plataforma web</small>
|
||||
<span>LinkedSign <em>v{packageInfo.version}</em></span>
|
||||
<small>Firma Electrónica @LinkedCorp</small>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@@ -112,12 +116,16 @@ export default function Layout({ children }) {
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
<div className="sidebar-group sidebar-group--disabled">
|
||||
<div className="sidebar-group">
|
||||
<div className="nav-group-label">Extra</div>
|
||||
<span className="nav-item nav-item--static">
|
||||
<ScrollText size={15} />
|
||||
<span>Auditoría</span>
|
||||
</span>
|
||||
<Link
|
||||
to="/legal"
|
||||
className={`nav-item ${location.pathname.startsWith('/legal') ? 'active' : ''}`}
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
>
|
||||
<Scale size={15} />
|
||||
<span>Legal</span>
|
||||
</Link>
|
||||
</div>
|
||||
</nav>
|
||||
<div className="sidebar-footer">
|
||||
@@ -144,14 +152,10 @@ export default function Layout({ children }) {
|
||||
<main className="main-content">
|
||||
<div className="topbar">
|
||||
<div className="topbar-title">{currentTitle}</div>
|
||||
<div className="search-box">
|
||||
<Search size={13} />
|
||||
<input placeholder="Buscar documentos..." aria-label="Buscar documentos" />
|
||||
<div className="topbar-brand-logo" aria-hidden="true">
|
||||
<span className="topbar-brand-by">by</span>
|
||||
<img src="/logoLinkedCorpTxtBlack.png" alt="LinkedCorp" />
|
||||
</div>
|
||||
<button className="btn btn-primary topbar-cta" type="button" onClick={() => navigate('/documents')}>
|
||||
<Plus size={14} />
|
||||
Nuevo documento
|
||||
</button>
|
||||
</div>
|
||||
{children}
|
||||
</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 'react-pdf/dist/Page/AnnotationLayer.css';
|
||||
import 'react-pdf/dist/Page/TextLayer.css';
|
||||
import { detectClientSignatureContext } from '../../utils/clientSignatureContext';
|
||||
|
||||
pdfjs.GlobalWorkerOptions.workerSrc = new URL(
|
||||
'pdfjs-dist/build/pdf.worker.min.mjs',
|
||||
@@ -12,12 +13,20 @@ const SIG_W = 200;
|
||||
const SIG_H = 60;
|
||||
|
||||
const getSignatureWidth = (style, signerName = '') => {
|
||||
if (style !== 'qr') return SIG_W;
|
||||
if (style !== 'qr' && style !== 'firmaec') return SIG_W;
|
||||
const normalizedName = signerName.trim();
|
||||
const calculatedWidth = 78 + (normalizedName.length * 4.9);
|
||||
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({
|
||||
pdfBlob,
|
||||
signerName,
|
||||
@@ -28,13 +37,37 @@ export default function PdfSignaturePlacer({
|
||||
signatureImage = null,
|
||||
onPlaced,
|
||||
}) {
|
||||
const clientSignatureContext = detectClientSignatureContext();
|
||||
const [numPages, setNumPages] = useState(null);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [sigBox, setSigBox] = useState(null); // { x, y } in container px
|
||||
const stageRef = useRef(null);
|
||||
const containerRef = useRef(null);
|
||||
const [pageSize, setPageSize] = useState(null); // rendered px size
|
||||
const [pageRenderWidth, setPageRenderWidth] = useState(600);
|
||||
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 container = containerRef.current;
|
||||
const canvas = container?.querySelector('.react-pdf__Page__canvas');
|
||||
@@ -59,12 +92,12 @@ export default function PdfSignaturePlacer({
|
||||
setPageSize({ width: page.width, height: page.height });
|
||||
};
|
||||
|
||||
const handleContainerClick = (e) => {
|
||||
const handlePointerDown = (event) => {
|
||||
const metrics = getCanvasMetrics();
|
||||
if (!metrics) return;
|
||||
|
||||
const x = e.clientX - metrics.canvasRect.left;
|
||||
const y = e.clientY - metrics.canvasRect.top;
|
||||
const x = event.clientX - metrics.canvasRect.left;
|
||||
const y = event.clientY - metrics.canvasRect.top;
|
||||
const clampedX = Math.min(Math.max(x, 0), metrics.width - signatureWidth);
|
||||
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 = () => {
|
||||
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') {
|
||||
return (
|
||||
<div className="sig-preview-body sig-preview-body--image">
|
||||
@@ -177,16 +245,17 @@ export default function PdfSignaturePlacer({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ref={stageRef} className="pdf-placer-stage">
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="pdf-placer-canvas"
|
||||
onClick={handleContainerClick}
|
||||
onPointerDown={handlePointerDown}
|
||||
style={{ cursor: 'crosshair', position: 'relative', display: 'inline-block' }}
|
||||
>
|
||||
<Document file={pdfBlob} onLoadSuccess={handleDocLoad}>
|
||||
<Page
|
||||
pageNumber={currentPage}
|
||||
width={600}
|
||||
width={pageRenderWidth}
|
||||
onLoadSuccess={(p) => setPageSize({
|
||||
width: p.width, height: p.height,
|
||||
pdfWidth: p.originalWidth, pdfHeight: p.originalHeight,
|
||||
@@ -197,16 +266,17 @@ export default function PdfSignaturePlacer({
|
||||
{sigBox && (
|
||||
<div
|
||||
className={`sig-preview-box sig-preview-box--${signatureStyle}`}
|
||||
data-background={signatureStyle === 'qr' ? qrBackground : undefined}
|
||||
data-border={signatureStyle === 'qr' ? qrBorder : undefined}
|
||||
data-background={signatureStyle === 'qr' || signatureStyle === 'firmaec' ? qrBackground : undefined}
|
||||
data-border={signatureStyle === 'qr' || signatureStyle === 'firmaec' ? qrBorder : undefined}
|
||||
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" />}
|
||||
{renderPreview()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{sigBox && (
|
||||
<div className="pdf-placer-actions">
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useRef } from 'react';
|
||||
import { QrCode, Briefcase, ImageIcon, Stamp, PanelsLeftBottom, PanelsRightBottom, CircleOff, Square } from 'lucide-react';
|
||||
import { QrCode, Briefcase, Stamp, PanelsLeftBottom, PanelsRightBottom, CircleOff, Square, ShieldCheck } from 'lucide-react';
|
||||
|
||||
const getQrPreviewWidth = (name = '') => {
|
||||
const extraWidth = Math.max(0, name.trim().length - 18) * 4;
|
||||
@@ -55,7 +54,7 @@ const STYLES = [
|
||||
</svg>
|
||||
<span className="sp-qr-hint">Escanear</span>
|
||||
</div>
|
||||
<div className="sp-divider-v" />
|
||||
{qrBorder === 'line' && <div className="sp-divider-v" />}
|
||||
</>
|
||||
)}
|
||||
<div className="sp-info sp-info--qr">
|
||||
@@ -65,7 +64,7 @@ const STYLES = [
|
||||
</div>
|
||||
{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}>
|
||||
<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 =>
|
||||
@@ -85,6 +84,61 @@ const STYLES = [
|
||||
</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',
|
||||
label: 'Profesional',
|
||||
@@ -102,36 +156,11 @@ const STYLES = [
|
||||
</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({
|
||||
value,
|
||||
onChange,
|
||||
signatureImage,
|
||||
onImageChange,
|
||||
signerName,
|
||||
qrPosition = 'left',
|
||||
onQrPositionChange,
|
||||
@@ -140,19 +169,8 @@ export default function SignatureStylePicker({
|
||||
qrBorder = 'none',
|
||||
onQrBorderChange,
|
||||
}) {
|
||||
const fileRef = useRef();
|
||||
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 (
|
||||
<div className="style-picker">
|
||||
<label className="field-label-standalone">Estilo de firma visual</label>
|
||||
@@ -176,14 +194,13 @@ export default function SignatureStylePicker({
|
||||
{s.preview({
|
||||
name: signerName || 'Milton González H.',
|
||||
date: today,
|
||||
image: signatureImage,
|
||||
qrPosition,
|
||||
qrBackground,
|
||||
qrBorder,
|
||||
})}
|
||||
</div>
|
||||
<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-inline-group">
|
||||
<button
|
||||
@@ -267,21 +284,6 @@ export default function SignatureStylePicker({
|
||||
);
|
||||
})}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 { login as apiLogin, logout as apiLogout, getMe } from '../api/auth';
|
||||
import { createContext, useContext, useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { login as apiLogin, logout as apiLogout, getMe, refreshSession as apiRefreshSession } from '../api/auth';
|
||||
import { updateSignatureAppearancePreference as apiUpdateSignatureAppearancePreference } from '../api/users';
|
||||
import {
|
||||
clearAccessToken,
|
||||
expireSession,
|
||||
getAccessToken,
|
||||
SESSION_EXPIRED_EVENT,
|
||||
setAccessToken,
|
||||
} from '../utils/session';
|
||||
|
||||
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 }) {
|
||||
const [user, setUser] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const lastActivityRef = useRef(Date.now());
|
||||
const lastRefreshRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('accessToken');
|
||||
const token = getAccessToken();
|
||||
if (token) {
|
||||
getMe()
|
||||
.then(({ data }) => setUser(data.data.user))
|
||||
.catch(() => {
|
||||
localStorage.removeItem('accessToken');
|
||||
localStorage.removeItem('refreshToken');
|
||||
clearAccessToken();
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
} 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 { data } = await apiLogin({ email, password });
|
||||
const { accessToken, refreshToken, user: userData } = data.data;
|
||||
localStorage.setItem('accessToken', accessToken);
|
||||
localStorage.setItem('refreshToken', refreshToken);
|
||||
const { accessToken, user: userData } = data.data;
|
||||
setAccessToken(accessToken);
|
||||
lastActivityRef.current = Date.now();
|
||||
lastRefreshRef.current = Date.now();
|
||||
setUser(userData);
|
||||
return userData;
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
const refreshToken = localStorage.getItem('refreshToken');
|
||||
try { await apiLogout(refreshToken); } catch { /* ignore */ }
|
||||
localStorage.removeItem('accessToken');
|
||||
localStorage.removeItem('refreshToken');
|
||||
try { await apiLogout(); } catch { /* ignore */ }
|
||||
clearAccessToken();
|
||||
setUser(null);
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -111,14 +111,19 @@ select {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 8px;
|
||||
background: var(--c-primary);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-brand-mark img {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 8px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.sidebar-brand-copy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -129,6 +134,16 @@ select {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
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 {
|
||||
@@ -317,33 +332,29 @@ select {
|
||||
color: var(--c-text);
|
||||
}
|
||||
|
||||
.search-box {
|
||||
width: 220px;
|
||||
.topbar-brand-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
background: var(--c-bg);
|
||||
border: 0.5px solid var(--c-border);
|
||||
border-radius: 8px;
|
||||
padding: 6px 10px;
|
||||
min-width: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.topbar-brand-by {
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
font-weight: 500;
|
||||
color: var(--c-muted);
|
||||
text-transform: lowercase;
|
||||
}
|
||||
|
||||
.search-box input {
|
||||
width: 100%;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
font-size: 12px;
|
||||
color: var(--c-text);
|
||||
}
|
||||
|
||||
.search-box input::placeholder {
|
||||
color: var(--c-muted);
|
||||
}
|
||||
|
||||
.topbar-cta {
|
||||
white-space: nowrap;
|
||||
.topbar-brand-logo img {
|
||||
display: block;
|
||||
height: 24px;
|
||||
width: auto;
|
||||
max-width: 180px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
/* ─── Page ───────────────────────────────────────────────────── */
|
||||
@@ -391,6 +402,7 @@ select {
|
||||
.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:hover { text-decoration: underline; }
|
||||
.auth-version { text-align: center; margin-top: 10px; font-size: 12px; color: var(--gray-400); }
|
||||
.auth-download-btn {
|
||||
justify-content: center;
|
||||
}
|
||||
@@ -431,6 +443,257 @@ select {
|
||||
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 ────────────────────────────────────────────────── */
|
||||
.btn {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
@@ -698,6 +961,12 @@ select {
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.legal-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.cert-card {
|
||||
background: #fff;
|
||||
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 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; }
|
||||
|
||||
/* ─── Sign page ──────────────────────────────────────────────── */
|
||||
@@ -751,6 +1047,49 @@ select {
|
||||
border: 0.5px solid var(--c-border);
|
||||
border-radius: 12px;
|
||||
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; }
|
||||
@@ -1140,6 +1479,10 @@ select {
|
||||
.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-stage {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.pdf-placer-canvas {
|
||||
border: 2px dashed var(--primary);
|
||||
border-radius: 0 0 8px 8px;
|
||||
@@ -1171,6 +1514,18 @@ select {
|
||||
border-color: transparent;
|
||||
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'] {
|
||||
background: rgba(242, 244, 255, 0.96);
|
||||
}
|
||||
@@ -1535,6 +1890,88 @@ select {
|
||||
flex-direction: column;
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
@@ -1669,6 +2106,173 @@ select {
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,.15);
|
||||
}
|
||||
|
||||
.guest-invitation-page {
|
||||
padding: 32px 16px;
|
||||
}
|
||||
|
||||
.guest-invitation-card {
|
||||
width: min(1100px, 100%);
|
||||
max-width: 1100px;
|
||||
}
|
||||
|
||||
.guest-invitation-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.guest-invitation-header h1 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.guest-invitation-header p {
|
||||
margin: 0;
|
||||
color: var(--gray-500);
|
||||
}
|
||||
|
||||
.guest-invitation-order {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
border-radius: 999px;
|
||||
background: var(--c-info-bg);
|
||||
color: var(--c-primary);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.invitation-terms-modal {
|
||||
max-width: 520px;
|
||||
}
|
||||
|
||||
.invitation-info-card,
|
||||
.invitation-owner-card,
|
||||
.invitation-completed-card {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.invitation-info-line {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
color: var(--gray-700);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.invitation-info-line svg {
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
color: var(--c-primary);
|
||||
}
|
||||
|
||||
.invitation-completed-card {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.invitation-completed-icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 50%;
|
||||
margin: 0 auto 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--c-success-bg);
|
||||
color: var(--c-success);
|
||||
}
|
||||
|
||||
.invitation-modal {
|
||||
width: min(920px, 96vw);
|
||||
}
|
||||
|
||||
.invitation-modal-body {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.invitation-owner-warnings {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.invitation-draft-list,
|
||||
.invitation-order-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.invitation-draft-row,
|
||||
.invitation-order-row {
|
||||
border: 1px solid var(--gray-200);
|
||||
border-radius: 12px;
|
||||
padding: 14px;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.invitation-order-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.invitation-order-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.invitation-order-row strong {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
color: var(--gray-900);
|
||||
}
|
||||
|
||||
.invitation-order-row span:not(.badge) {
|
||||
display: block;
|
||||
color: var(--gray-500);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.invitation-status-badge {
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.invitation-status-queued {
|
||||
background: var(--gray-100);
|
||||
color: var(--gray-600);
|
||||
}
|
||||
|
||||
.invitation-status-sent,
|
||||
.invitation-status-opened {
|
||||
background: var(--c-info-bg);
|
||||
color: var(--c-primary);
|
||||
}
|
||||
|
||||
.invitation-status-signed {
|
||||
background: var(--c-success-bg);
|
||||
color: var(--c-success);
|
||||
}
|
||||
|
||||
.invitation-owner-actions {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.invitation-remove-btn {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.invitation-inline-delete {
|
||||
min-width: 92px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* ─── Responsive ─────────────────────────────────────────────── */
|
||||
@media (max-width: 768px) {
|
||||
.app-layout {
|
||||
@@ -1767,9 +2371,8 @@ select {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
|
||||
.search-box,
|
||||
.topbar-cta {
|
||||
width: 100%;
|
||||
.topbar-brand-logo {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.page {
|
||||
@@ -1826,6 +2429,23 @@ select {
|
||||
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 {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
@@ -1872,6 +2492,7 @@ select {
|
||||
|
||||
.pdf-placer-canvas {
|
||||
overflow: auto;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.pdf-placer-canvas .react-pdf__Page,
|
||||
@@ -1901,6 +2522,17 @@ select {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.guest-invitation-header,
|
||||
.invitation-order-row {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.invitation-order-actions {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.style-card-inline-tools,
|
||||
.image-upload-area {
|
||||
justify-content: flex-start;
|
||||
|
||||
@@ -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 { useNavigate, Link } from 'react-router-dom';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate, Link, useSearchParams } from 'react-router-dom';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { Download, PenTool } from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { SESSION_EXPIRED_MESSAGE, translateApiError } from '../../utils/session';
|
||||
import { resolveAndroidApkUrl } from '../../config/api';
|
||||
|
||||
export default function LoginPage() {
|
||||
const { login } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [form, setForm] = useState({ email: '', password: '' });
|
||||
const [loading, setLoading] = useState(false);
|
||||
const apiBaseUrl = (import.meta.env.VITE_API_URL || 'http://localhost:4000').replace(/\/$/, '');
|
||||
const androidApkUrl = `${apiBaseUrl}/downloads/android-apk`;
|
||||
const androidApkUrl = resolveAndroidApkUrl();
|
||||
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 }));
|
||||
|
||||
@@ -22,12 +35,16 @@ export default function LoginPage() {
|
||||
navigate('/dashboard');
|
||||
} catch (err) {
|
||||
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') {
|
||||
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') {
|
||||
toast.error(message);
|
||||
} else if (code === 'UNLOCK_MAIL_SEND_FAILED') {
|
||||
toast.error(message);
|
||||
} else {
|
||||
toast.error(message);
|
||||
}
|
||||
@@ -56,14 +73,17 @@ export default function LoginPage() {
|
||||
<button type="submit" className="btn btn-primary btn-full" disabled={loading}>
|
||||
{loading ? 'Ingresando...' : 'Iniciar sesión'}
|
||||
</button>
|
||||
{showAndroidDownload && (
|
||||
<a className="btn btn-secondary btn-full auth-download-btn" href={androidApkUrl}>
|
||||
<Download size={14} />
|
||||
Descargar app Android
|
||||
</a>
|
||||
)}
|
||||
</form>
|
||||
<p className="auth-link">
|
||||
¿No tienes cuenta? <Link to="/register">Registrarse</Link>
|
||||
</p>
|
||||
<p className="auth-version">Versión {__APP_VERSION__}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,46 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
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 { 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 = '') => ([
|
||||
{ label: 'Al menos 8 caracteres', valid: password.length >= 8 },
|
||||
@@ -14,12 +52,68 @@ const getPasswordChecks = (password = '') => ([
|
||||
|
||||
export default function RegisterPage() {
|
||||
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 passwordChecks = getPasswordChecks(form.password);
|
||||
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) => {
|
||||
e.preventDefault();
|
||||
@@ -31,16 +125,32 @@ export default function RegisterPage() {
|
||||
toast.error('Las contraseñas no coinciden');
|
||||
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);
|
||||
try {
|
||||
await register({
|
||||
email: form.email,
|
||||
password: form.password,
|
||||
});
|
||||
toast.success('Cuenta creada. Revisa tu correo para confirmar tu usuario.');
|
||||
const payload = new FormData();
|
||||
payload.append('email', form.email);
|
||||
payload.append('password', form.password);
|
||||
payload.append('certPassword', form.certPassword);
|
||||
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');
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.error || 'Error al registrarse');
|
||||
toast.error(translateApiError(err, 'Error al registrarse'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -78,7 +188,74 @@ export default function RegisterPage() {
|
||||
<label>Confirmar 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={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'}
|
||||
</button>
|
||||
</form>
|
||||
@@ -86,6 +263,61 @@ export default function RegisterPage() {
|
||||
¿Ya tienes cuenta? <Link to="/login">Iniciar sesión</Link>
|
||||
</p>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
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 { listDocuments, uploadDocument, deleteDocument, downloadDocument } from '../../api/documents';
|
||||
import { listSignedDocuments, downloadSigned } from '../../api/signatures';
|
||||
import { Upload, Trash2, Download, PenTool, FileText, RefreshCw, Eye, X } from 'lucide-react';
|
||||
import { addInvitationFlowInvitees, createInvitationFlow, deleteInvitation, getInvitationFlow } from '../../api/invitations';
|
||||
import { Upload, Trash2, Download, PenTool, FileText, RefreshCw, Eye, X, Users, Mail, ShieldAlert, Route } from 'lucide-react';
|
||||
import { Document, Page, pdfjs } from 'react-pdf';
|
||||
import 'react-pdf/dist/Page/AnnotationLayer.css';
|
||||
import 'react-pdf/dist/Page/TextLayer.css';
|
||||
@@ -22,9 +23,13 @@ const STATUS_LABELS = {
|
||||
|
||||
export default function DocumentsPage() {
|
||||
const navigate = useNavigate();
|
||||
const pageSizeOptions = [5, 10, 15, 20];
|
||||
const [docs, setDocs] = useState([]);
|
||||
const [signedDocs, setSignedDocs] = useState([]);
|
||||
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 [uploading, setUploading] = useState(false);
|
||||
const fileInputRef = useRef();
|
||||
@@ -35,33 +40,53 @@ export default function DocumentsPage() {
|
||||
const [numPages, setNumPages] = useState(null);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [loadingViewer, setLoadingViewer] = useState(false);
|
||||
const [invitationModalOpen, setInvitationModalOpen] = useState(false);
|
||||
const [selectedSignedDoc, setSelectedSignedDoc] = useState(null);
|
||||
const [flowLoading, setFlowLoading] = useState(false);
|
||||
const [flowData, setFlowData] = useState(null);
|
||||
const [invitees, setInvitees] = useState([{ name: '', email: '' }]);
|
||||
const [creatingFlow, setCreatingFlow] = useState(false);
|
||||
const [deletingInvitationId, setDeletingInvitationId] = useState(null);
|
||||
|
||||
const loadDocs = () => {
|
||||
setLoading(true);
|
||||
Promise.all([
|
||||
const loadDocs = async ({ silent = false } = {}) => {
|
||||
if (!silent) setLoading(true);
|
||||
try {
|
||||
const [documentsResponse, signedResponse] = await Promise.all([
|
||||
listDocuments({ limit: 50 }),
|
||||
listSignedDocuments(),
|
||||
])
|
||||
.then(([documentsResponse, signedResponse]) => {
|
||||
]);
|
||||
setDocs(documentsResponse.data.data.rows);
|
||||
setTotal(documentsResponse.data.data.total);
|
||||
setSignedDocs(signedResponse.data.data || []);
|
||||
})
|
||||
.catch(() => toast.error('Error al cargar documentos'))
|
||||
.finally(() => setLoading(false));
|
||||
} catch {
|
||||
toast.error('Error al cargar documentos');
|
||||
} finally {
|
||||
if (!silent) setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
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 file = e.target.files[0];
|
||||
if (!file) return;
|
||||
if (file.type !== 'application/pdf') { toast.error('Solo se permiten archivos PDF'); return; }
|
||||
setUploading(true);
|
||||
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');
|
||||
loadDocs();
|
||||
void loadDocs({ silent: true });
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.error || 'Error al subir documento');
|
||||
} finally {
|
||||
@@ -115,6 +140,99 @@ export default function DocumentsPage() {
|
||||
|
||||
const closeViewer = () => { setViewerBlob(null); setViewerTitle(''); };
|
||||
|
||||
const closeInvitationModal = () => {
|
||||
setInvitationModalOpen(false);
|
||||
setSelectedSignedDoc(null);
|
||||
setFlowData(null);
|
||||
setInvitees([{ name: '', email: '' }]);
|
||||
setCreatingFlow(false);
|
||||
};
|
||||
|
||||
const openInvitationModal = async (signedDoc) => {
|
||||
setSelectedSignedDoc(signedDoc);
|
||||
setInvitationModalOpen(true);
|
||||
setFlowLoading(true);
|
||||
setFlowData(null);
|
||||
try {
|
||||
const { data } = await getInvitationFlow(signedDoc.document_id);
|
||||
setFlowData(data.data);
|
||||
} catch (error) {
|
||||
toast.error(error.response?.data?.error || 'No se pudo cargar la cadena de firmas');
|
||||
} finally {
|
||||
setFlowLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateInvitee = (index, field, value) => {
|
||||
setInvitees((current) => current.map((invitee, inviteeIndex) => (
|
||||
inviteeIndex === index ? { ...invitee, [field]: value } : invitee
|
||||
)));
|
||||
};
|
||||
|
||||
const addInvitee = () => {
|
||||
if (invitees.length >= 5) {
|
||||
toast.error('Solo puedes invitar hasta 5 personas');
|
||||
return;
|
||||
}
|
||||
setInvitees((current) => [...current, { name: '', email: '' }]);
|
||||
};
|
||||
|
||||
const removeInvitee = (index) => {
|
||||
setInvitees((current) => current.filter((_, inviteeIndex) => inviteeIndex !== index));
|
||||
};
|
||||
|
||||
const handleCreateFlow = async (event) => {
|
||||
event.preventDefault();
|
||||
if (!selectedSignedDoc) return;
|
||||
|
||||
const cleanedInvitees = invitees
|
||||
.map((invitee) => ({ name: invitee.name.trim(), email: invitee.email.trim().toLowerCase() }))
|
||||
.filter((invitee) => invitee.name && invitee.email);
|
||||
|
||||
if (cleanedInvitees.length === 0) {
|
||||
toast.error('Agrega al menos un firmante externo');
|
||||
return;
|
||||
}
|
||||
|
||||
setCreatingFlow(true);
|
||||
try {
|
||||
const request = flowData
|
||||
? addInvitationFlowInvitees(selectedSignedDoc.document_id, { invitees: cleanedInvitees })
|
||||
: createInvitationFlow({
|
||||
documentId: selectedSignedDoc.document_id,
|
||||
invitees: cleanedInvitees,
|
||||
});
|
||||
const { data } = await request;
|
||||
setFlowData(data.data);
|
||||
setInvitees([{ name: '', email: '' }]);
|
||||
toast.success('Flujo de firmas externas creado');
|
||||
} catch (error) {
|
||||
toast.error(error.response?.data?.error || 'No se pudo crear la cadena de firmas');
|
||||
} finally {
|
||||
setCreatingFlow(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteInvitation = async (invitee) => {
|
||||
if (!confirm(`¿Eliminar la invitación de ${invitee.invitee_name || invitee.inviteeName}?`)) return;
|
||||
|
||||
setDeletingInvitationId(invitee.id);
|
||||
try {
|
||||
const { data } = await deleteInvitation(invitee.id);
|
||||
setFlowData(data.data);
|
||||
if ((data.data?.invitees || []).length < 5) {
|
||||
setInvitees((current) => current.length > 0 ? current : [{ name: '', email: '' }]);
|
||||
}
|
||||
toast.success('Invitación eliminada');
|
||||
} catch (error) {
|
||||
toast.error(error.response?.data?.error || 'No se pudo eliminar la invitación');
|
||||
} finally {
|
||||
setDeletingInvitationId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const canAddMoreInvitees = (flowData?.invitees?.length || 0) < 5;
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
|
||||
@@ -154,12 +272,191 @@ export default function DocumentsPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{invitationModalOpen && (
|
||||
<div className="modal-overlay" onClick={closeInvitationModal}>
|
||||
<div className="pdf-viewer-modal invitation-modal" onClick={(event) => event.stopPropagation()}>
|
||||
<div className="pdf-viewer-header">
|
||||
<span className="pdf-viewer-title"><Users size={16} /> Firmas externas</span>
|
||||
<div className="pdf-viewer-controls">
|
||||
<button className="btn-icon" type="button" onClick={closeInvitationModal}><X size={18} /></button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="pdf-viewer-body invitation-modal-body">
|
||||
{flowLoading ? (
|
||||
<div className="loading-area"><div className="spinner" /></div>
|
||||
) : (
|
||||
<div className="documents-sections">
|
||||
<div className="form-card invitation-owner-card">
|
||||
<h3>{selectedSignedDoc?.document_title || selectedSignedDoc?.signed_filename}</h3>
|
||||
<div className="invitation-owner-warnings">
|
||||
<div className="invitation-info-line">
|
||||
<ShieldAlert size={16} />
|
||||
<span>Solo puedes invitar personas que dispongan de firma electrónica P12/PFX.</span>
|
||||
</div>
|
||||
<div className="invitation-info-line">
|
||||
<Route size={16} />
|
||||
<span>El sistema enviará automáticamente cada solicitud según el orden definido.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{flowData ? (
|
||||
<>
|
||||
{canAddMoreInvitees && (
|
||||
<div className="form-card">
|
||||
<h3>Agregar más firmantes externos</h3>
|
||||
<form onSubmit={handleCreateFlow}>
|
||||
<div className="invitation-draft-list">
|
||||
{invitees.map((invitee, index) => (
|
||||
<div key={`invitee-${index}`} className="invitation-draft-row">
|
||||
<div className="form-group">
|
||||
<label>Nombre del firmante nuevo {index + 1}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={invitee.name}
|
||||
onChange={(event) => updateInvitee(index, 'name', event.target.value)}
|
||||
placeholder="Nombre completo"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Correo del firmante nuevo {index + 1}</label>
|
||||
<input
|
||||
type="email"
|
||||
value={invitee.email}
|
||||
onChange={(event) => updateInvitee(index, 'email', event.target.value)}
|
||||
placeholder="correo@dominio.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{invitees.length > 1 && (
|
||||
<button
|
||||
className="btn btn-ghost invitation-remove-btn"
|
||||
type="button"
|
||||
onClick={() => removeInvitee(index)}
|
||||
>
|
||||
Quitar
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="modal-actions invitation-owner-actions">
|
||||
<button className="btn btn-secondary" type="button" onClick={addInvitee}>
|
||||
Agregar firmante
|
||||
</button>
|
||||
<button className="btn btn-primary" type="submit" disabled={creatingFlow}>
|
||||
{creatingFlow ? 'Agregando...' : 'Guardar nuevos firmantes'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="form-card">
|
||||
<h3>Orden actual de firmantes</h3>
|
||||
<div className="invitation-order-list">
|
||||
{flowData.invitees?.map((invitee) => (
|
||||
<div key={invitee.id} className="invitation-order-row">
|
||||
<div>
|
||||
<strong>{invitee.order_index || invitee.orderIndex}. {invitee.invitee_name || invitee.inviteeName}</strong>
|
||||
<span>{invitee.invitee_email || invitee.inviteeEmail}</span>
|
||||
</div>
|
||||
<div className="invitation-order-actions">
|
||||
<span className={`badge invitation-status-badge invitation-status-${invitee.status}`}>
|
||||
{invitee.status === 'queued' ? 'En espera' : invitee.status === 'sent' ? 'Enviado' : invitee.status === 'opened' ? 'Abierto' : invitee.status === 'signed' ? 'Firmado' : invitee.status}
|
||||
</span>
|
||||
{invitee.status !== 'signed' && (
|
||||
<button
|
||||
className="btn btn-ghost invitation-inline-delete"
|
||||
type="button"
|
||||
disabled={deletingInvitationId === invitee.id}
|
||||
onClick={() => handleDeleteInvitation(invitee)}
|
||||
>
|
||||
{deletingInvitationId === invitee.id ? 'Eliminando...' : 'Eliminar'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="form-card">
|
||||
<h3>Configurar firmantes externos</h3>
|
||||
<form onSubmit={handleCreateFlow}>
|
||||
<div className="invitation-draft-list">
|
||||
{invitees.map((invitee, index) => (
|
||||
<div key={`invitee-${index}`} className="invitation-draft-row">
|
||||
<div className="form-group">
|
||||
<label>Nombre del firmante {index + 1}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={invitee.name}
|
||||
onChange={(event) => updateInvitee(index, 'name', event.target.value)}
|
||||
placeholder="Nombre completo"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Correo del firmante {index + 1}</label>
|
||||
<input
|
||||
type="email"
|
||||
value={invitee.email}
|
||||
onChange={(event) => updateInvitee(index, 'email', event.target.value)}
|
||||
placeholder="correo@dominio.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{invitees.length > 1 && (
|
||||
<button
|
||||
className="btn btn-ghost invitation-remove-btn"
|
||||
type="button"
|
||||
onClick={() => removeInvitee(index)}
|
||||
>
|
||||
Quitar
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="modal-actions invitation-owner-actions">
|
||||
<button className="btn btn-secondary" type="button" onClick={addInvitee}>
|
||||
Agregar firmante
|
||||
</button>
|
||||
<button className="btn btn-primary" type="submit" disabled={creatingFlow}>
|
||||
{creatingFlow ? 'Creando...' : 'Enviar primera invitación'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h2>Documentos</h2>
|
||||
<p>{total} documento{total !== 1 ? 's' : ''} original{total !== 1 ? 'es' : ''}</p>
|
||||
</div>
|
||||
<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-primary" onClick={() => fileInputRef.current?.click()} disabled={uploading}>
|
||||
<Upload size={16} /> {uploading ? 'Subiendo...' : 'Subir PDF'}
|
||||
@@ -194,7 +491,7 @@ export default function DocumentsPage() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{docs.map((doc) => {
|
||||
{pagedDocs.map((doc) => {
|
||||
const s = STATUS_LABELS[doc.status] || STATUS_LABELS.pending;
|
||||
return (
|
||||
<tr key={doc.id}>
|
||||
@@ -223,6 +520,15 @@ export default function DocumentsPage() {
|
||||
</table>
|
||||
</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 className="documents-section">
|
||||
@@ -248,7 +554,7 @@ export default function DocumentsPage() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{signedDocs.map((doc) => (
|
||||
{pagedSignedDocs.map((doc) => (
|
||||
<tr key={doc.id}>
|
||||
<td className="doc-name"><FileText size={14} /> {doc.signed_filename}</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')}>
|
||||
<Eye size={15} />
|
||||
</button>
|
||||
<button className="btn-icon" title="Solicitar firmas externas" onClick={() => openInvitationModal(doc)}>
|
||||
<Users size={15} />
|
||||
</button>
|
||||
<button className="btn-icon" title="Descargar PDF firmado" onClick={() => handleDownloadSigned(doc.id, doc.signed_filename)}>
|
||||
<Download size={15} />
|
||||
</button>
|
||||
@@ -268,6 +577,15 @@ export default function DocumentsPage() {
|
||||
</table>
|
||||
</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>
|
||||
</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>
|
||||
);
|
||||
}
|
||||