From 1074c7c9d2b3bd5b249032d6ced49bca8f77247f Mon Sep 17 00:00:00 2001 From: Milton Gonzalez Date: Mon, 6 Apr 2026 20:33:59 -0500 Subject: [PATCH] Initial LinkedSign platform --- .gitignore | 81 + AGENT.md | 172 + android/README.md | 102 + android/app/build.gradle | 78 + android/app/proguard-rules.pro | 1 + android/app/src/main/AndroidManifest.xml | 34 + .../net/linkedcorp/linkedsign/MainActivity.kt | 31 + .../linkedcorp/linkedsign/data/ApiService.kt | 86 + .../linkedsign/data/LinkedSignRepository.kt | 299 ++ .../linkedcorp/linkedsign/data/LocalCache.kt | 152 + .../net/linkedcorp/linkedsign/data/Models.kt | 185 + .../linkedsign/data/SessionStore.kt | 114 + .../linkedcorp/linkedsign/ui/LinkedSignApp.kt | 2072 ++++++++++ .../linkedcorp/linkedsign/ui/MainViewModel.kt | 194 + .../linkedcorp/linkedsign/ui/theme/Color.kt | 27 + .../linkedcorp/linkedsign/ui/theme/Theme.kt | 107 + .../linkedcorp/linkedsign/ui/theme/Type.kt | 85 + .../res/drawable/ic_launcher_foreground.xml | 15 + .../res/mipmap-anydpi-v26/ic_launcher.xml | 5 + .../mipmap-anydpi-v26/ic_launcher_round.xml | 5 + android/app/src/main/res/values/colors.xml | 5 + .../res/values/ic_launcher_background.xml | 4 + android/app/src/main/res/values/strings.xml | 3 + android/app/src/main/res/values/themes.xml | 8 + android/app/src/main/res/xml/file_paths.xml | 9 + android/build.gradle | 25 + android/gradle.properties | 4 + android/gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 43583 bytes .../gradle/wrapper/gradle-wrapper.properties | 5 + android/gradlew | 252 ++ android/settings.gradle | 2 + android_firma_electronica.html | 457 +++ android_react/.gitignore | 6 + android_react/App.js | 18 + android_react/README.md | 101 + android_react/app.json | 22 + android_react/babel.config.js | 6 + android_react/package.json | 29 + android_react/src/api/auth.js | 7 + android_react/src/api/certificates.js | 7 + android_react/src/api/client.js | 78 + android_react/src/api/documents.js | 7 + android_react/src/api/signatures.js | 4 + android_react/src/components/PasswordRules.js | 47 + android_react/src/components/PrimaryButton.js | 47 + .../src/components/ScreenContainer.js | 26 + android_react/src/config/env.js | 8 + android_react/src/context/AuthContext.js | 86 + android_react/src/context/SyncContext.js | 53 + android_react/src/navigation/AppNavigator.js | 74 + .../src/screens/app/CertificatesScreen.js | 125 + .../src/screens/app/DashboardScreen.js | 112 + .../src/screens/app/DocumentsScreen.js | 153 + .../src/screens/app/ProfileScreen.js | 104 + android_react/src/screens/app/SignScreen.js | 156 + android_react/src/screens/auth/LoginScreen.js | 106 + .../src/screens/auth/RegisterScreen.js | 107 + .../src/screens/auth/VerifyNoticeScreen.js | 38 + android_react/src/theme/colors.js | 13 + android_react/src/utils/files.js | 17 + android_react/src/utils/passwordRules.js | 10 + apk/README.md | 48 + apk/build-apk.sh | 16 + backend/.env.example | 20 + backend/IMPLEMENTATION_SUMMARY.md | 145 + backend/README.md | 210 ++ backend/package-lock.json | 1878 +++++++++ backend/package.json | 32 + backend/scripts/logrotate.sh | 12 + backend/src/app.js | 29 + backend/src/config/db.js | 17 + backend/src/config/env.js | 45 + backend/src/config/multer.js | 57 + backend/src/middlewares/authRateLimiter.js | 15 + backend/src/middlewares/authenticate.js | 19 + backend/src/middlewares/errorHandler.js | 29 + backend/src/middlewares/fileValidator.js | 28 + backend/src/middlewares/notFound.js | 7 + backend/src/modules/audit/audit.model.js | 11 + backend/src/modules/audit/audit.repository.js | 44 + backend/src/modules/audit/audit.service.js | 11 + backend/src/modules/auth/auth.controller.js | 90 + backend/src/modules/auth/auth.repository.js | 115 + backend/src/modules/auth/auth.routes.js | 17 + backend/src/modules/auth/auth.service.js | 286 ++ backend/src/modules/auth/auth.validator.js | 48 + .../auth/password-change.repository.js | 43 + .../certificates/certificates.controller.js | 36 + .../certificates/certificates.repository.js | 78 + .../certificates/certificates.routes.js | 15 + .../certificates/certificates.service.js | 168 + .../modules/documents/documents.controller.js | 44 + .../modules/documents/documents.repository.js | 40 + .../src/modules/documents/documents.routes.js | 16 + .../modules/documents/documents.service.js | 59 + .../src/modules/health/health.controller.js | 15 + .../src/modules/health/health.repository.js | 9 + backend/src/modules/health/health.routes.js | 8 + backend/src/modules/health/health.service.js | 14 + backend/src/modules/mail/mail.repository.js | 49 + backend/src/modules/mail/mail.service.js | 128 + .../signatures/signatures.controller.js | 67 + .../signatures/signatures.repository.js | 114 + .../modules/signatures/signatures.routes.js | 17 + .../modules/signatures/signatures.service.js | 200 + .../src/modules/signing/signing.service.js | 313 ++ backend/src/modules/signing/signing.test.js | 25 + backend/src/modules/users/.gitkeep | 1 + backend/src/modules/users/users.controller.js | 15 + backend/src/modules/users/users.repository.js | 66 + backend/src/modules/users/users.routes.js | 11 + backend/src/modules/users/users.service.js | 49 + backend/src/routes/index.js | 18 + backend/src/server.js | 6 + backend/src/utils/apiResponse.js | 20 + backend/src/utils/certificatePath.js | 17 + backend/src/utils/jwt.js | 16 + backend/src/utils/mockJwt.js | 31 + backend/src/utils/storagePath.js | 29 + frontend/.gitignore | 24 + frontend/README.md | 98 + frontend/eslint.config.js | 29 + frontend/index.html | 13 + frontend/package-lock.json | 3352 +++++++++++++++++ frontend/package.json | 32 + frontend/public/favicon.svg | 1 + frontend/public/icons.svg | 24 + frontend/src/App.css | 1 + frontend/src/App.jsx | 54 + frontend/src/api/auth.js | 9 + frontend/src/api/certificates.js | 12 + frontend/src/api/client.js | 42 + frontend/src/api/documents.js | 12 + frontend/src/api/public.js | 5 + frontend/src/api/signatures.js | 6 + frontend/src/api/users.js | 4 + frontend/src/assets/hero.png | Bin 0 -> 44919 bytes frontend/src/assets/react.svg | 1 + frontend/src/assets/vite.svg | 1 + frontend/src/components/layout/Layout.jsx | 160 + .../src/components/ui/ChangePasswordModal.jsx | 104 + frontend/src/components/ui/PasswordModal.jsx | 48 + .../src/components/ui/PdfSignaturePlacer.jsx | 221 ++ frontend/src/components/ui/PrivateRoute.jsx | 9 + .../components/ui/SignatureStylePicker.jsx | 287 ++ frontend/src/contexts/AuthContext.jsx | 58 + frontend/src/index.css | 1939 ++++++++++ frontend/src/main.jsx | 10 + .../pages/auth/ConfirmPasswordChangePage.jsx | 57 + frontend/src/pages/auth/LoginPage.jsx | 64 + frontend/src/pages/auth/RegisterPage.jsx | 91 + frontend/src/pages/auth/VerifyEmailPage.jsx | 57 + .../pages/certificates/CertificatesPage.jsx | 210 ++ .../src/pages/dashboard/DashboardPage.jsx | 124 + .../src/pages/documents/DocumentsPage.jsx | 276 ++ .../signature-style/SignatureStylePage.jsx | 76 + .../src/pages/signatures/SignaturesPage.jsx | 300 ++ .../src/pages/verify/VerifySignaturePage.jsx | 98 + frontend/vite.config.js | 11 + 159 files changed, 19072 insertions(+) create mode 100644 .gitignore create mode 100644 AGENT.md create mode 100644 android/README.md create mode 100644 android/app/build.gradle create mode 100644 android/app/proguard-rules.pro create mode 100644 android/app/src/main/AndroidManifest.xml create mode 100644 android/app/src/main/java/net/linkedcorp/linkedsign/MainActivity.kt create mode 100644 android/app/src/main/java/net/linkedcorp/linkedsign/data/ApiService.kt create mode 100644 android/app/src/main/java/net/linkedcorp/linkedsign/data/LinkedSignRepository.kt create mode 100644 android/app/src/main/java/net/linkedcorp/linkedsign/data/LocalCache.kt create mode 100644 android/app/src/main/java/net/linkedcorp/linkedsign/data/Models.kt create mode 100644 android/app/src/main/java/net/linkedcorp/linkedsign/data/SessionStore.kt create mode 100644 android/app/src/main/java/net/linkedcorp/linkedsign/ui/LinkedSignApp.kt create mode 100644 android/app/src/main/java/net/linkedcorp/linkedsign/ui/MainViewModel.kt create mode 100644 android/app/src/main/java/net/linkedcorp/linkedsign/ui/theme/Color.kt create mode 100644 android/app/src/main/java/net/linkedcorp/linkedsign/ui/theme/Theme.kt create mode 100644 android/app/src/main/java/net/linkedcorp/linkedsign/ui/theme/Type.kt create mode 100644 android/app/src/main/res/drawable/ic_launcher_foreground.xml create mode 100644 android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 android/app/src/main/res/values/colors.xml create mode 100644 android/app/src/main/res/values/ic_launcher_background.xml create mode 100644 android/app/src/main/res/values/strings.xml create mode 100644 android/app/src/main/res/values/themes.xml create mode 100644 android/app/src/main/res/xml/file_paths.xml create mode 100644 android/build.gradle create mode 100644 android/gradle.properties create mode 100644 android/gradle/wrapper/gradle-wrapper.jar create mode 100644 android/gradle/wrapper/gradle-wrapper.properties create mode 100755 android/gradlew create mode 100644 android/settings.gradle create mode 100644 android_firma_electronica.html create mode 100644 android_react/.gitignore create mode 100644 android_react/App.js create mode 100644 android_react/README.md create mode 100644 android_react/app.json create mode 100644 android_react/babel.config.js create mode 100644 android_react/package.json create mode 100644 android_react/src/api/auth.js create mode 100644 android_react/src/api/certificates.js create mode 100644 android_react/src/api/client.js create mode 100644 android_react/src/api/documents.js create mode 100644 android_react/src/api/signatures.js create mode 100644 android_react/src/components/PasswordRules.js create mode 100644 android_react/src/components/PrimaryButton.js create mode 100644 android_react/src/components/ScreenContainer.js create mode 100644 android_react/src/config/env.js create mode 100644 android_react/src/context/AuthContext.js create mode 100644 android_react/src/context/SyncContext.js create mode 100644 android_react/src/navigation/AppNavigator.js create mode 100644 android_react/src/screens/app/CertificatesScreen.js create mode 100644 android_react/src/screens/app/DashboardScreen.js create mode 100644 android_react/src/screens/app/DocumentsScreen.js create mode 100644 android_react/src/screens/app/ProfileScreen.js create mode 100644 android_react/src/screens/app/SignScreen.js create mode 100644 android_react/src/screens/auth/LoginScreen.js create mode 100644 android_react/src/screens/auth/RegisterScreen.js create mode 100644 android_react/src/screens/auth/VerifyNoticeScreen.js create mode 100644 android_react/src/theme/colors.js create mode 100644 android_react/src/utils/files.js create mode 100644 android_react/src/utils/passwordRules.js create mode 100644 apk/README.md create mode 100755 apk/build-apk.sh create mode 100644 backend/.env.example create mode 100644 backend/IMPLEMENTATION_SUMMARY.md create mode 100644 backend/README.md create mode 100644 backend/package-lock.json create mode 100644 backend/package.json create mode 100755 backend/scripts/logrotate.sh create mode 100644 backend/src/app.js create mode 100644 backend/src/config/db.js create mode 100644 backend/src/config/env.js create mode 100644 backend/src/config/multer.js create mode 100644 backend/src/middlewares/authRateLimiter.js create mode 100644 backend/src/middlewares/authenticate.js create mode 100644 backend/src/middlewares/errorHandler.js create mode 100644 backend/src/middlewares/fileValidator.js create mode 100644 backend/src/middlewares/notFound.js create mode 100644 backend/src/modules/audit/audit.model.js create mode 100644 backend/src/modules/audit/audit.repository.js create mode 100644 backend/src/modules/audit/audit.service.js create mode 100644 backend/src/modules/auth/auth.controller.js create mode 100644 backend/src/modules/auth/auth.repository.js create mode 100644 backend/src/modules/auth/auth.routes.js create mode 100644 backend/src/modules/auth/auth.service.js create mode 100644 backend/src/modules/auth/auth.validator.js create mode 100644 backend/src/modules/auth/password-change.repository.js create mode 100644 backend/src/modules/certificates/certificates.controller.js create mode 100644 backend/src/modules/certificates/certificates.repository.js create mode 100644 backend/src/modules/certificates/certificates.routes.js create mode 100644 backend/src/modules/certificates/certificates.service.js create mode 100644 backend/src/modules/documents/documents.controller.js create mode 100644 backend/src/modules/documents/documents.repository.js create mode 100644 backend/src/modules/documents/documents.routes.js create mode 100644 backend/src/modules/documents/documents.service.js create mode 100644 backend/src/modules/health/health.controller.js create mode 100644 backend/src/modules/health/health.repository.js create mode 100644 backend/src/modules/health/health.routes.js create mode 100644 backend/src/modules/health/health.service.js create mode 100644 backend/src/modules/mail/mail.repository.js create mode 100644 backend/src/modules/mail/mail.service.js create mode 100644 backend/src/modules/signatures/signatures.controller.js create mode 100644 backend/src/modules/signatures/signatures.repository.js create mode 100644 backend/src/modules/signatures/signatures.routes.js create mode 100644 backend/src/modules/signatures/signatures.service.js create mode 100644 backend/src/modules/signing/signing.service.js create mode 100644 backend/src/modules/signing/signing.test.js create mode 100644 backend/src/modules/users/.gitkeep create mode 100644 backend/src/modules/users/users.controller.js create mode 100644 backend/src/modules/users/users.repository.js create mode 100644 backend/src/modules/users/users.routes.js create mode 100644 backend/src/modules/users/users.service.js create mode 100644 backend/src/routes/index.js create mode 100644 backend/src/server.js create mode 100644 backend/src/utils/apiResponse.js create mode 100644 backend/src/utils/certificatePath.js create mode 100644 backend/src/utils/jwt.js create mode 100644 backend/src/utils/mockJwt.js create mode 100644 backend/src/utils/storagePath.js create mode 100644 frontend/.gitignore create mode 100644 frontend/README.md create mode 100644 frontend/eslint.config.js create mode 100644 frontend/index.html create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/public/favicon.svg create mode 100644 frontend/public/icons.svg create mode 100644 frontend/src/App.css create mode 100644 frontend/src/App.jsx create mode 100644 frontend/src/api/auth.js create mode 100644 frontend/src/api/certificates.js create mode 100644 frontend/src/api/client.js create mode 100644 frontend/src/api/documents.js create mode 100644 frontend/src/api/public.js create mode 100644 frontend/src/api/signatures.js create mode 100644 frontend/src/api/users.js create mode 100644 frontend/src/assets/hero.png create mode 100644 frontend/src/assets/react.svg create mode 100644 frontend/src/assets/vite.svg create mode 100644 frontend/src/components/layout/Layout.jsx create mode 100644 frontend/src/components/ui/ChangePasswordModal.jsx create mode 100644 frontend/src/components/ui/PasswordModal.jsx create mode 100644 frontend/src/components/ui/PdfSignaturePlacer.jsx create mode 100644 frontend/src/components/ui/PrivateRoute.jsx create mode 100644 frontend/src/components/ui/SignatureStylePicker.jsx create mode 100644 frontend/src/contexts/AuthContext.jsx create mode 100644 frontend/src/index.css create mode 100644 frontend/src/main.jsx create mode 100644 frontend/src/pages/auth/ConfirmPasswordChangePage.jsx create mode 100644 frontend/src/pages/auth/LoginPage.jsx create mode 100644 frontend/src/pages/auth/RegisterPage.jsx create mode 100644 frontend/src/pages/auth/VerifyEmailPage.jsx create mode 100644 frontend/src/pages/certificates/CertificatesPage.jsx create mode 100644 frontend/src/pages/dashboard/DashboardPage.jsx create mode 100644 frontend/src/pages/documents/DocumentsPage.jsx create mode 100644 frontend/src/pages/signature-style/SignatureStylePage.jsx create mode 100644 frontend/src/pages/signatures/SignaturesPage.jsx create mode 100644 frontend/src/pages/verify/VerifySignaturePage.jsx create mode 100644 frontend/vite.config.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3576349 --- /dev/null +++ b/.gitignore @@ -0,0 +1,81 @@ +# --- Secrets & env --- +.env +.env.* +!.env.example +backend/.env +backend/.env.* +!backend/.env.example +frontend/.env +frontend/.env.* +!frontend/.env.example +android/local.properties +android/keystore.properties +android/*.jks +android/*.keystore + +# --- Dependencies --- +node_modules/ +backend/node_modules/ +frontend/node_modules/ +android_react/node_modules/ + +# --- Build outputs --- +dist/ +build/ +coverage/ +frontend/dist/ +backend/dist/ +android/.gradle/ +android/.gradle-dist/ +android/.gradle-home/ +android/build/ +android/app/build/ +android/apk/*.apk +android/apk/*.aab +android/apk/*.apks +android/apk/*.idsig +backend/*.p12 +backend/*.pfx + +# --- Logs --- +logs/ +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* +backend/logs/ + +# --- Persistent app data --- +storage/ +backend/storage/ +backend/uploads/ +backend/tmp/ +backend/temp/ + +# --- Android / mobile --- +.gradle/ +.kotlin/ +android/.idea/ +android/.cxx/ +android/captures/ +android/local.properties +android_react/.expo/ +android_react/android/ +android_react/ios/ + +# --- Editor / OS --- +.DS_Store +Thumbs.db +.idea/ +.codex +.vscode/* +!.vscode/extensions.json +*.swp +*.swo + +# --- Local artifacts / references --- +apk/*.apk +Screenshot_*.png +test.pdf diff --git a/AGENT.md b/AGENT.md new file mode 100644 index 0000000..74dbc9a --- /dev/null +++ b/AGENT.md @@ -0,0 +1,172 @@ +# LinkedSign – AI Development Agent (Codex) + +## Context +You are working on a full-stack web application called **LinkedSign**, a digital document signing platform using PFX/P12 certificates. + +## Tech Stack (MANDATORY) +- Backend: Node.js + Express +- Frontend: React (Vite) +- Database: MariaDB +- Auth: JWT (access + refresh) +- Encryption: + - bcrypt or argon2 for passwords + - AES-256-GCM for sensitive data +- Architecture: REST API (frontend/backend separated) + +## Environment +- OS: Debian 12 (no GUI) +- IDE: code-server +- Root path: /home/mgonzalez/desarrollo/react/linkedsign/ + +--- + +## Global Rules (CRITICAL) + +- Always maintain modular architecture (domain-based modules) +- NEVER break existing structure +- Prefer minimal changes over rewrites +- All code must be production-ready +- Always validate inputs +- Always apply security best practices by default +- Always log critical actions +- Never expose secrets in code +- Use environment variables (.env) + +--- + +## Backend Architecture + +Each module must follow: + +controller → service → repository → routes → validator + +Modules: + +- auth +- users +- subscriptions +- documents +- certificates +- signatures +- invitations +- mail +- audit +- legal +- health + +--- + +## Security Requirements + +- Rate limiting on login +- Brute-force protection +- Helmet enabled +- CORS restricted +- Input validation (Joi/Zod) +- File validation (PDF only) +- Encrypt: + - PFX files (or protect access) + - certificate passwords +- Audit: + - login attempts + - IP address + - timestamps + - document actions + +--- + +## Storage Rules + +- Documents stored in: + /storage/documentos/YYYY/MM/ + +- NEVER store files inside database + +--- + +## Frontend Rules + +- Use React with modular structure (features-based) +- Responsive UI (mobile + desktop) +- Clean modern UI (prefer Tailwind or clean CSS) +- Use services layer for API calls +- Use hooks for logic reuse + +--- + +## Features to Build + +1. Authentication (with email confirmation) +2. Document management (upload, download, delete) +3. Certificate management (PFX/P12) +4. Digital signature (single + multi-user) +5. Invitations (guest signing via link) +6. Audit system +7. Legal acceptance (terms & privacy) + +--- + +## API Requirements + +- All endpoints must be documented +- Provide cURL examples for each endpoint +- Use consistent response format: + +{ + success: boolean, + data: any, + error: string | null +} + +--- + +## Code Quality + +- Use async/await +- Use centralized error handling +- Avoid duplicated logic +- Use clear naming conventions + +--- + +## Testing + +- Add tests when logic is critical +- Do not break existing functionality + +--- + +## Workflow + +Before writing code: + +1. Analyze current structure +2. Propose plan +3. Wait for approval (IMPORTANT) +4. Then implement + +--- + +## Language Rules + +- Explain in Spanish +- Code in English + +--- + +## Skills Usage (IMPORTANT) + +You can use skills when appropriate: + +- Use "refactor" skill for improving structure +- Use "security review" skill before finalizing modules +- Use "ui improvement" skill for frontend polishing +- Use "generate tests" skill for critical flows + +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. diff --git a/android/README.md b/android/README.md new file mode 100644 index 0000000..0ee1479 --- /dev/null +++ b/android/README.md @@ -0,0 +1,102 @@ +# LinkedSign Android Native + +Cliente Android nativo de LinkedSign construido con **Kotlin + Jetpack Compose**. + +## Objetivo + +- compartir autenticación con la web +- consumir el mismo backend REST +- mantener cache local en el teléfono +- sincronizar datos solo cuando el usuario lo solicite + +## Qué incluye + +- login +- registro +- aviso de validación por correo +- dashboard +- documentos originales y firmados +- subida de PDF +- subida de certificados `.p12/.pfx` +- firmado básico desde Android +- perfil +- solicitud de cambio de contraseña +- sesión local segura +- desbloqueo biométrico con huella si el dispositivo lo soporta +- cache local SQLite +- tipografía centralizada por variables en `ui/theme/Type.kt` +- UI Android refactorizada siguiendo la referencia visual de `android_firma_electronica.html` + +## Arquitectura usada + +- Kotlin nativo +- Jetpack Compose +- Retrofit +- OkHttp +- Moshi +- EncryptedSharedPreferences +- SQLiteOpenHelper + +## Estructura + +```text +android/ + app/ + build.gradle + src/main/ + AndroidManifest.xml + java/net/linkedcorp/linkedsign/ + res/values/ + build.gradle + gradle.properties + settings.gradle + README.md +``` + +## Backend + +Por defecto la app apunta a: + +```text +http://192.168.222.72:4000 +``` + +Ese valor está en: + +[app/build.gradle](/home/mgonzalez/desarrollo/react/linkedsign/android/app/build.gradle) + +Si cambias IP o dominio, actualiza: + +```groovy +buildConfigField "String", "API_BASE_URL", "\"http://TU_HOST:4000\"" +``` + +## Abrir en Android Studio + +1. Abre la carpeta: + `/home/mgonzalez/desarrollo/react/linkedsign/android` +2. Deja que Android Studio sincronice Gradle +3. Configura el SDK si hace falta +4. Ejecuta en emulador o dispositivo físico + +## APK generado + +Se generó un build debug en: + +[`android/apk/LinkedSign-debug.apk`](/home/mgonzalez/desarrollo/react/linkedsign/android/apk/LinkedSign-debug.apk) + +Comando usado: + +```bash +source /etc/profile.d/android-sdk.sh +cd /home/mgonzalez/desarrollo/react/linkedsign/android +GRADLE_USER_HOME=/home/mgonzalez/desarrollo/react/linkedsign/android/.gradle-home ./gradlew assembleDebug +``` + +## Notas + +- `android_react/` conserva la versión anterior hecha con Expo/React Native. +- `android/` es ahora la base nativa instalable. +- La app Android y la web se complementan: misma cuenta, mismo backend, cache local y sincronización manual. +- El enlace de validación por correo sigue siendo servido por el frontend web actual. +- La referencia visual principal usada para el rediseño móvil está en [`android_firma_electronica.html`](/home/mgonzalez/desarrollo/react/linkedsign/android_firma_electronica.html). diff --git a/android/app/build.gradle b/android/app/build.gradle new file mode 100644 index 0000000..47c503e --- /dev/null +++ b/android/app/build.gradle @@ -0,0 +1,78 @@ +apply plugin: 'com.android.application' +apply plugin: 'org.jetbrains.kotlin.android' + +android { + namespace 'net.linkedcorp.linkedsign' + compileSdk 35 + + defaultConfig { + applicationId "net.linkedcorp.linkedsign" + minSdk 26 + targetSdk 35 + versionCode 1 + versionName "0.1.0" + + buildConfigField "String", "API_BASE_URL", "\"http://192.168.222.72:4000\"" + } + + buildTypes { + debug { + minifyEnabled false + } + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = '17' + } + + buildFeatures { + compose true + buildConfig true + } + + composeOptions { + kotlinCompilerExtensionVersion compose_compiler_version + } + + packaging { + resources { + excludes += '/META-INF/{AL2.0,LGPL2.1}' + } + } +} + +dependencies { + implementation platform('androidx.compose:compose-bom:2024.09.00') + + implementation 'androidx.appcompat:appcompat:1.7.0' + implementation 'androidx.core:core-ktx:1.13.1' + implementation 'androidx.activity:activity-compose:1.9.2' + implementation 'androidx.biometric:biometric:1.1.0' + implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.8.6' + implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.8.6' + implementation 'androidx.compose.ui:ui' + implementation 'androidx.compose.ui:ui-tooling-preview' + implementation 'androidx.compose.material3:material3' + implementation 'androidx.compose.material:material-icons-extended' + implementation 'androidx.security:security-crypto:1.1.0-alpha06' + + implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1' + + implementation 'com.squareup.retrofit2:retrofit:2.11.0' + implementation 'com.squareup.retrofit2:converter-moshi:2.11.0' + implementation 'com.squareup.okhttp3:okhttp:4.12.0' + implementation 'com.squareup.okhttp3:logging-interceptor:4.12.0' + implementation 'com.squareup.moshi:moshi-kotlin:1.15.1' + + debugImplementation 'androidx.compose.ui:ui-tooling' + debugImplementation 'androidx.compose.ui:ui-test-manifest' +} diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro new file mode 100644 index 0000000..3d2acee --- /dev/null +++ b/android/app/proguard-rules.pro @@ -0,0 +1 @@ +# LinkedSign Android native app diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..5e43a51 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/java/net/linkedcorp/linkedsign/MainActivity.kt b/android/app/src/main/java/net/linkedcorp/linkedsign/MainActivity.kt new file mode 100644 index 0000000..2df4abe --- /dev/null +++ b/android/app/src/main/java/net/linkedcorp/linkedsign/MainActivity.kt @@ -0,0 +1,31 @@ +package net.linkedcorp.linkedsign + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.material3.Surface +import androidx.lifecycle.viewmodel.compose.viewModel +import net.linkedcorp.linkedsign.ui.LinkedSignApp +import net.linkedcorp.linkedsign.ui.MainViewModel +import net.linkedcorp.linkedsign.ui.theme.LinkedSignTheme + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + + setContent { + LinkedSignTheme { + Surface(color = androidx.compose.material3.MaterialTheme.colorScheme.background) { + val mainViewModel: MainViewModel = viewModel() + LinkedSignApp( + viewModel = mainViewModel, + appContext = applicationContext, + activity = this + ) + } + } + } + } +} diff --git a/android/app/src/main/java/net/linkedcorp/linkedsign/data/ApiService.kt b/android/app/src/main/java/net/linkedcorp/linkedsign/data/ApiService.kt new file mode 100644 index 0000000..1c39b9d --- /dev/null +++ b/android/app/src/main/java/net/linkedcorp/linkedsign/data/ApiService.kt @@ -0,0 +1,86 @@ +package net.linkedcorp.linkedsign.data + +import okhttp3.MultipartBody +import okhttp3.RequestBody +import retrofit2.http.Body +import retrofit2.http.DELETE +import retrofit2.http.GET +import retrofit2.http.Header +import retrofit2.http.Multipart +import retrofit2.http.POST +import retrofit2.http.Part +import retrofit2.http.PATCH +import retrofit2.http.Query + +interface LinkedSignApiService { + @POST("auth/login") + suspend fun login(@Body body: LoginRequest): ApiEnvelope + + @POST("auth/register") + suspend fun register(@Body body: RegisterRequest): ApiEnvelope + + @POST("auth/refresh") + suspend fun refresh(@Body body: RefreshRequest): ApiEnvelope + + @POST("auth/logout") + suspend fun logout(@Body body: RefreshRequest): ApiEnvelope + + @GET("auth/me") + suspend fun me(@Header("Authorization") authorization: String): ApiEnvelope + + @PATCH("auth/change-password") + suspend fun changePassword( + @Header("Authorization") authorization: String, + @Body body: ChangePasswordRequest + ): ApiEnvelope + + @GET("documents") + suspend fun listDocuments( + @Header("Authorization") authorization: String, + @Query("limit") limit: Int = 100 + ): ApiEnvelope + + @Multipart + @POST("documents") + suspend fun uploadDocument( + @Header("Authorization") authorization: String, + @Part file: MultipartBody.Part + ): ApiEnvelope + + @DELETE("documents/{id}") + suspend fun deleteDocument( + @Header("Authorization") authorization: String, + @retrofit2.http.Path("id") id: Int + ): ApiEnvelope + + @GET("certificates") + suspend fun listCertificates( + @Header("Authorization") authorization: String + ): ApiEnvelope> + + @Multipart + @POST("certificates") + suspend fun uploadCertificate( + @Header("Authorization") authorization: String, + @Part file: MultipartBody.Part, + @Part("alias") alias: RequestBody, + @Part("password") password: RequestBody + ): ApiEnvelope + + @GET("signatures/mine") + suspend fun listSignedDocuments( + @Header("Authorization") authorization: String + ): ApiEnvelope> + + @DELETE("signatures/{id}") + suspend fun deleteSignedDocument( + @Header("Authorization") authorization: String, + @retrofit2.http.Path("id") id: Int + ): ApiEnvelope + + @POST("signatures/sign") + suspend fun signDocument( + @Header("Authorization") authorization: String, + @Body body: SignRequest + ): ApiEnvelope +} diff --git a/android/app/src/main/java/net/linkedcorp/linkedsign/data/LinkedSignRepository.kt b/android/app/src/main/java/net/linkedcorp/linkedsign/data/LinkedSignRepository.kt new file mode 100644 index 0000000..30a3fcc --- /dev/null +++ b/android/app/src/main/java/net/linkedcorp/linkedsign/data/LinkedSignRepository.kt @@ -0,0 +1,299 @@ +package net.linkedcorp.linkedsign.data + +import android.content.Context +import android.net.Uri +import net.linkedcorp.linkedsign.BuildConfig +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.MultipartBody +import okhttp3.OkHttpClient +import okhttp3.RequestBody.Companion.asRequestBody +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.logging.HttpLoggingInterceptor +import com.squareup.moshi.Moshi +import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory +import retrofit2.HttpException +import retrofit2.Retrofit +import retrofit2.converter.moshi.MoshiConverterFactory +import java.io.File +import java.io.FileInputStream +import java.io.IOException +import java.util.Locale + +class LinkedSignRepository(private val context: Context) { + private val sessionStore = SessionStore(context) + private val localCache = LocalCache(context) + private val moshi = Moshi.Builder().add(KotlinJsonAdapterFactory()).build() + + private val service: LinkedSignApiService by lazy { + val logger = HttpLoggingInterceptor().apply { + level = HttpLoggingInterceptor.Level.BASIC + } + + Retrofit.Builder() + .baseUrl("${BuildConfig.API_BASE_URL.trimEnd('/')}/") + .client( + OkHttpClient.Builder() + .addInterceptor(logger) + .build() + ) + .addConverterFactory(MoshiConverterFactory.create(moshi)) + .build() + .create(LinkedSignApiService::class.java) + } + + fun loadLocalSnapshot(): Pair { + return sessionStore.load() to localCache.loadSnapshot() + } + + suspend fun bootstrap(): Pair { + val session = sessionStore.load() + if (session.accessToken.isNullOrBlank()) { + return session to localCache.loadSnapshot() + } + + return try { + val user = withAuth { auth -> unwrap(service.me(auth)).user } + val updated = session.copy(user = user) + sessionStore.save(updated) + localCache.saveProfile(user) + updated to localCache.loadSnapshot() + } catch (_: Exception) { + sessionStore.clearSession() + SessionSnapshot() to localCache.loadSnapshot() + } + } + + suspend fun login(email: String, password: String): SessionSnapshot { + return execute { + val response = unwrap(service.login(LoginRequest(email.trim(), password))) + val session = SessionSnapshot( + accessToken = response.accessToken, + refreshToken = response.refreshToken, + user = response.user + ) + sessionStore.save(session) + localCache.saveProfile(response.user) + session + } + } + + suspend fun register(email: String, password: String): RegisterData { + return execute { + unwrap(service.register(RegisterRequest(email.trim(), password))) + } + } + + suspend fun logout() { + execute { + val session = sessionStore.load() + try { + if (!session.refreshToken.isNullOrBlank()) { + unwrap(service.logout(RefreshRequest(session.refreshToken))) + } + } finally { + sessionStore.clearSession() + } + } + } + + suspend fun biometricLogin(): SessionSnapshot { + val credentials = sessionStore.loadBiometricCredentials() + ?: throw AppException("No hay credenciales biométricas guardadas.") + return login(credentials.first, credentials.second) + } + + fun biometricState(): BiometricAccessState = sessionStore.loadBiometricState() + + fun enableBiometricAccess(email: String, password: String) { + sessionStore.saveBiometricAccess(email.trim(), password) + } + + fun declineBiometricAccess() { + 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 syncAll(): CacheSnapshot = execute { withAuth { auth -> + val documents = unwrap(service.listDocuments(auth)).rows + val certificates = unwrap(service.listCertificates(auth)) + val signedDocuments = unwrap(service.listSignedDocuments(auth)) + val user = unwrap(service.me(auth)).user + + localCache.saveProfile(user) + localCache.replaceDocuments(documents) + localCache.replaceCertificates(certificates) + localCache.replaceSignedDocuments(signedDocuments) + localCache.setMeta("last_sync_at", java.time.OffsetDateTime.now().toString()) + + localCache.loadSnapshot() + } } + + suspend fun uploadDocument(uri: Uri, fileName: String): DocumentItem = execute { withAuth { auth -> + val part = withContext(Dispatchers.IO) { + createFilePart(uri, fileName, "application/pdf", "file") + } + val item = unwrap(service.uploadDocument(auth, part)) + syncAll() + item + } } + + suspend fun uploadCertificate(uri: Uri, fileName: String, alias: String, password: String): CertificateItem = execute { withAuth { auth -> + val part = withContext(Dispatchers.IO) { + createFilePart(uri, fileName, "application/octet-stream", "file") + } + val aliasBody = alias.toRequestBody("text/plain".toMediaType()) + val passwordBody = password.toRequestBody("text/plain".toMediaType()) + val item = unwrap(service.uploadCertificate(auth, part, aliasBody, passwordBody)) + syncAll() + item + } } + + suspend fun deleteDocument(documentId: Int): SimpleMessageData = execute { withAuth { auth -> + val result = unwrap(service.deleteDocument(auth, documentId)) + syncAll() + result + } } + + suspend fun deleteSignedDocument(signatureId: Int): SimpleMessageData = execute { withAuth { auth -> + val result = unwrap(service.deleteSignedDocument(auth, signatureId)) + syncAll() + result + } } + + suspend fun signDocument(request: SignRequest): SignData = execute { withAuth { auth -> + val result = unwrap(service.signDocument(auth, request)) + syncAll() + result + } } + + fun getAccessToken(): String? = sessionStore.load().accessToken + + private suspend fun withAuth(block: suspend (String) -> T): T { + val session = sessionStore.load() + val accessToken = session.accessToken ?: throw AppException("Debes iniciar sesión.") + + return try { + block("Bearer $accessToken") + } 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 = session.copy( + accessToken = refresh.accessToken, + refreshToken = refresh.refreshToken + ) + sessionStore.save(updated) + block("Bearer ${refresh.accessToken}") + } + } + + private suspend fun execute(block: suspend () -> T): T { + return try { + block() + } catch (error: AppException) { + throw error + } catch (error: HttpException) { + throw mapHttpException(error) + } catch (_: IOException) { + throw AppException("No se pudo conectar con el servidor. Verifica la IP, el puerto y que el backend esté activo.") + } catch (error: Exception) { + throw AppException(error.message ?: "La operación no pudo completarse (${error::class.java.simpleName}).") + } + } + + private fun unwrap(response: ApiEnvelope): T { + if (!response.success || response.data == null) { + throw AppException(response.error ?: "La operación no pudo completarse.") + } + return response.data + } + + private fun mapHttpException(error: HttpException): AppException { + val raw = error.response()?.errorBody()?.string() + if (raw.isNullOrBlank()) { + return AppException("Error de comunicación con el servidor.") + } + + return try { + val adapter = moshi.adapter(ErrorEnvelope::class.java) + val parsed = adapter.fromJson(raw) + AppException( + message = parsed?.error ?: "Error de comunicación con el servidor.", + code = parsed?.code + ) + } catch (_: Exception) { + AppException(raw) + } + } + + private fun createFilePart(uri: Uri, fileName: String, mimeType: String, fieldName: String): MultipartBody.Part { + val safeFileName = fileName.ifBlank { "documento.pdf" } + val extension = safeFileName.substringAfterLast('.', "").lowercase(Locale.getDefault()) + val suffix = if (extension.isNotBlank()) ".${extension.take(10)}" else ".bin" + + val tempFile = try { + File.createTempFile("linkedsign-upload-", suffix, context.cacheDir) + } catch (error: Exception) { + throw AppException("No se pudo preparar el archivo para subirlo.") + } + + try { + when { + uri.scheme.equals("file", ignoreCase = true) -> { + val sourcePath = uri.path ?: throw AppException("No se pudo resolver el archivo seleccionado.") + val sourceFile = File(sourcePath) + if (!sourceFile.exists() || !sourceFile.canRead()) { + throw AppException("No se pudo leer el archivo seleccionado.") + } + sourceFile.inputStream().use { input -> + tempFile.outputStream().use { output -> input.copyTo(output) } + } + } + else -> { + var copied = false + + try { + val descriptor = context.contentResolver.openFileDescriptor(uri, "r") + if (descriptor != null) { + descriptor.use { pfd -> + FileInputStream(pfd.fileDescriptor).use { input -> + tempFile.outputStream().use { output -> input.copyTo(output) } + } + } + copied = true + } + } catch (_: Exception) { + } + + if (!copied) { + val inputStream = context.contentResolver.openInputStream(uri) + ?: throw AppException("No se pudo leer el archivo seleccionado.") + inputStream.use { input -> + tempFile.outputStream().use { output -> input.copyTo(output) } + } + } + } + } + } catch (error: SecurityException) { + throw AppException("Android perdió acceso al archivo. Intenta seleccionarlo otra vez.") + } catch (error: IOException) { + throw AppException("No se pudo copiar el archivo seleccionado.") + } catch (error: AppException) { + throw error + } + + val requestBody = tempFile.asRequestBody(mimeType.toMediaType()) + return MultipartBody.Part.createFormData(fieldName, safeFileName, requestBody) + } +} diff --git a/android/app/src/main/java/net/linkedcorp/linkedsign/data/LocalCache.kt b/android/app/src/main/java/net/linkedcorp/linkedsign/data/LocalCache.kt new file mode 100644 index 0000000..7bd99b5 --- /dev/null +++ b/android/app/src/main/java/net/linkedcorp/linkedsign/data/LocalCache.kt @@ -0,0 +1,152 @@ +package net.linkedcorp.linkedsign.data + +import android.content.ContentValues +import android.content.Context +import android.database.sqlite.SQLiteDatabase +import android.database.sqlite.SQLiteOpenHelper +import com.squareup.moshi.Moshi +import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory + +private class LinkedSignDbHelper(context: Context) : + SQLiteOpenHelper(context, "linkedsign-mobile.db", null, 1) { + + override fun onCreate(db: SQLiteDatabase) { + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS sync_meta ( + key TEXT PRIMARY KEY NOT NULL, + value TEXT + ) + """.trimIndent() + ) + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS profile_cache ( + id INTEGER PRIMARY KEY NOT NULL, + payload TEXT NOT NULL + ) + """.trimIndent() + ) + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS documents_cache ( + id INTEGER PRIMARY KEY NOT NULL, + payload TEXT NOT NULL + ) + """.trimIndent() + ) + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS certificates_cache ( + id INTEGER PRIMARY KEY NOT NULL, + payload TEXT NOT NULL + ) + """.trimIndent() + ) + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS signed_documents_cache ( + id INTEGER PRIMARY KEY NOT NULL, + payload TEXT NOT NULL + ) + """.trimIndent() + ) + } + + override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) = Unit +} + +class LocalCache(context: Context) { + private val dbHelper = LinkedSignDbHelper(context) + private val moshi = Moshi.Builder().add(KotlinJsonAdapterFactory()).build() + private val userAdapter = moshi.adapter(SessionUser::class.java) + private val documentAdapter = moshi.adapter(DocumentItem::class.java) + private val certificateAdapter = moshi.adapter(CertificateItem::class.java) + private val signedAdapter = moshi.adapter(SignedDocumentItem::class.java) + + fun loadSnapshot(): CacheSnapshot { + val db = dbHelper.readableDatabase + return CacheSnapshot( + user = querySinglePayload(db, "profile_cache", userAdapter), + documents = queryAllPayloads(db, "documents_cache", documentAdapter), + certificates = queryAllPayloads(db, "certificates_cache", certificateAdapter), + signedDocuments = queryAllPayloads(db, "signed_documents_cache", signedAdapter), + lastSyncAt = getMeta(db, "last_sync_at") + ) + } + + fun saveProfile(user: SessionUser?) { + val db = dbHelper.writableDatabase + db.delete("profile_cache", null, null) + user ?: return + val values = ContentValues().apply { + put("id", user.id) + put("payload", userAdapter.toJson(user)) + } + db.insert("profile_cache", null, values) + } + + fun replaceDocuments(rows: List) = replaceTable("documents_cache", rows, documentAdapter) { it.id } + + fun replaceCertificates(rows: List) = replaceTable("certificates_cache", rows, certificateAdapter) { it.id } + + fun replaceSignedDocuments(rows: List) = replaceTable("signed_documents_cache", rows, signedAdapter) { it.id } + + fun setMeta(key: String, value: String) { + val db = dbHelper.writableDatabase + val values = ContentValues().apply { + put("key", key) + put("value", value) + } + db.insertWithOnConflict("sync_meta", null, values, SQLiteDatabase.CONFLICT_REPLACE) + } + + private fun replaceTable( + table: String, + rows: List, + adapter: com.squareup.moshi.JsonAdapter, + idSelector: (T) -> Int + ) { + val db = dbHelper.writableDatabase + db.beginTransaction() + try { + db.delete(table, null, null) + rows.forEach { row -> + val values = ContentValues().apply { + put("id", idSelector(row)) + put("payload", adapter.toJson(row)) + } + db.insert(table, null, values) + } + db.setTransactionSuccessful() + } finally { + db.endTransaction() + } + } + + private fun getMeta(db: SQLiteDatabase, key: String): String? = + db.rawQuery("SELECT value FROM sync_meta WHERE key = ?", arrayOf(key)).use { cursor -> + if (cursor.moveToFirst()) cursor.getString(0) else null + } + + private fun querySinglePayload( + db: SQLiteDatabase, + table: String, + adapter: com.squareup.moshi.JsonAdapter + ): T? = db.rawQuery("SELECT payload FROM $table LIMIT 1", null).use { cursor -> + if (!cursor.moveToFirst()) return null + adapter.fromJson(cursor.getString(0)) + } + + private fun queryAllPayloads( + db: SQLiteDatabase, + table: String, + adapter: com.squareup.moshi.JsonAdapter + ): List = db.rawQuery("SELECT payload FROM $table ORDER BY id DESC", null).use { cursor -> + val result = mutableListOf() + while (cursor.moveToNext()) { + adapter.fromJson(cursor.getString(0))?.let(result::add) + } + result + } +} diff --git a/android/app/src/main/java/net/linkedcorp/linkedsign/data/Models.kt b/android/app/src/main/java/net/linkedcorp/linkedsign/data/Models.kt new file mode 100644 index 0000000..9bbdd2f --- /dev/null +++ b/android/app/src/main/java/net/linkedcorp/linkedsign/data/Models.kt @@ -0,0 +1,185 @@ +package net.linkedcorp.linkedsign.data + +import com.squareup.moshi.Json + +data class ApiEnvelope( + val success: Boolean, + val data: T?, + val error: String? +) + +data class ErrorEnvelope( + val success: Boolean? = null, + val data: Any? = null, + val error: String? = null, + val code: String? = null +) + +data class SessionUser( + val id: Int, + val name: String?, + val email: String, + val role: String?, + @Json(name = "signaturePreferences") + val signaturePreferences: SignaturePreferences? = null +) + +data class SignaturePreferences( + val signatureStyle: String? = "qr", + val qrPosition: String? = "right", + val qrBackground: String? = "transparent", + val qrBorder: String? = "none", + val signatureImageBase64: String? = null +) + +data class LoginRequest( + val email: String, + val password: String +) + +data class LoginData( + val accessToken: String, + val refreshToken: String, + val user: SessionUser +) + +data class RegisterRequest( + val email: String, + val password: String +) + +data class RegisterData( + val message: String?, + val userId: Int? +) + +data class RefreshRequest( + val refreshToken: String +) + +data class RefreshData( + val accessToken: String, + val refreshToken: String +) + +data class SimpleMessageData( + val message: String +) + +data class MeData( + val user: SessionUser +) + +data class ChangePasswordRequest( + val currentPassword: String, + val newPassword: String +) + +data class DocumentListData( + val rows: List = emptyList(), + val total: Int = 0, + val limit: Int = 0, + val offset: Int = 0 +) + +data class DocumentItem( + val id: Int, + val title: String?, + val filename: String?, + val status: String?, + @Json(name = "created_at") + val createdAt: String?, + @Json(name = "file_size") + val fileSize: Long? +) + +data class CertificateItem( + val id: Int, + val alias: String?, + @Json(name = "subject_cn") + val subjectCn: String?, + @Json(name = "issuer_cn") + val issuerCn: String?, + @Json(name = "valid_from") + val validFrom: String?, + @Json(name = "valid_to") + val validTo: String?, + @Json(name = "is_active") + val isActive: Int?, + @Json(name = "has_password") + val hasPassword: Int?, + @Json(name = "certificate_count") + val certificateCount: Int?, + @Json(name = "chain_status") + val chainStatus: String? +) + +data class SignedDocumentItem( + val id: Int, + @Json(name = "document_id") + val documentId: Int?, + @Json(name = "signed_filename") + val signedFilename: String?, + @Json(name = "document_title") + val documentTitle: String?, + @Json(name = "certificate_alias") + val certificateAlias: String?, + @Json(name = "signed_at") + val signedAt: String? +) + +data class SignRequest( + val documentId: Int, + val certificateId: Int, + val certPassword: String?, + val reason: String?, + val location: String?, + val page: Int = 0, + val rect: SignatureRect? = null, + val signatureStyle: String?, + val qrPosition: String?, + val qrBackground: String?, + val qrBorder: String?, + val signatureImageBase64: String? = null +) + +data class SignatureRect( + val x: Int, + val y: Int, + val width: Int, + val height: Int +) + +data class SignData( + val sigId: Int?, + val signedFilename: String?, + val documentId: Int?, + val verificationCode: String?, + val verificationUrl: String? +) + +data class SessionSnapshot( + val accessToken: String? = null, + val refreshToken: String? = null, + val user: SessionUser? = null +) + +data class BiometricAccessState( + val enabled: Boolean = false, + val configured: Boolean = false, + val email: String? = null, + val hasCredentials: Boolean = false +) + +data class CacheSnapshot( + val user: SessionUser? = null, + val documents: List = emptyList(), + val certificates: List = emptyList(), + val signedDocuments: List = emptyList(), + val lastSyncAt: String? = null +) + +class AppException( + override val message: String, + val code: String? = null +) : Exception(message) diff --git a/android/app/src/main/java/net/linkedcorp/linkedsign/data/SessionStore.kt b/android/app/src/main/java/net/linkedcorp/linkedsign/data/SessionStore.kt new file mode 100644 index 0000000..7308521 --- /dev/null +++ b/android/app/src/main/java/net/linkedcorp/linkedsign/data/SessionStore.kt @@ -0,0 +1,114 @@ +package net.linkedcorp.linkedsign.data + +import android.content.Context +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKey +import com.squareup.moshi.Moshi +import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory + +class SessionStore(context: Context) { + private val moshi = Moshi.Builder().add(KotlinJsonAdapterFactory()).build() + private val userAdapter = moshi.adapter(SessionUser::class.java) + + private val masterKey = MasterKey.Builder(context) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build() + + private val preferences = EncryptedSharedPreferences.create( + context, + "linkedsign_secure_session", + masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM + ) + + fun load(): SessionSnapshot { + val accessToken = preferences.getString(KEY_ACCESS, null) + val refreshToken = preferences.getString(KEY_REFRESH, null) + val userJson = preferences.getString(KEY_USER, null) + val user = userJson?.let { userAdapter.fromJson(it) } + return SessionSnapshot(accessToken, refreshToken, user) + } + + fun save(session: SessionSnapshot) { + preferences.edit() + .putString(KEY_ACCESS, session.accessToken) + .putString(KEY_REFRESH, session.refreshToken) + .putString(KEY_USER, session.user?.let { userAdapter.toJson(it) }) + .apply() + } + + fun clear() { + preferences.edit().clear().apply() + } + + fun clearSession() { + preferences.edit() + .remove(KEY_ACCESS) + .remove(KEY_REFRESH) + .remove(KEY_USER) + .apply() + } + + 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) + + return BiometricAccessState( + enabled = enabled, + configured = configured, + email = email, + hasCredentials = !email.isNullOrBlank() && !password.isNullOrBlank() + ) + } + + fun saveBiometricAccess(email: String, password: String) { + preferences.edit() + .putBoolean(KEY_BIO_ENABLED, true) + .putBoolean(KEY_BIO_CONFIGURED, true) + .putString(KEY_BIO_EMAIL, email) + .putString(KEY_BIO_PASSWORD, password) + .apply() + } + + fun declineBiometricAccess() { + preferences.edit() + .putBoolean(KEY_BIO_ENABLED, false) + .putBoolean(KEY_BIO_CONFIGURED, true) + .remove(KEY_BIO_EMAIL) + .remove(KEY_BIO_PASSWORD) + .apply() + } + + fun updateBiometricCredentials(email: String, password: String) { + if (!preferences.getBoolean(KEY_BIO_ENABLED, false)) { + return + } + + preferences.edit() + .putString(KEY_BIO_EMAIL, email) + .putString(KEY_BIO_PASSWORD, password) + .apply() + } + + fun loadBiometricCredentials(): Pair? { + val email = preferences.getString(KEY_BIO_EMAIL, null) + val password = preferences.getString(KEY_BIO_PASSWORD, null) + if (email.isNullOrBlank() || password.isNullOrBlank()) { + return null + } + return email to password + } + + 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_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" + } +} diff --git a/android/app/src/main/java/net/linkedcorp/linkedsign/ui/LinkedSignApp.kt b/android/app/src/main/java/net/linkedcorp/linkedsign/ui/LinkedSignApp.kt new file mode 100644 index 0000000..78b9d32 --- /dev/null +++ b/android/app/src/main/java/net/linkedcorp/linkedsign/ui/LinkedSignApp.kt @@ -0,0 +1,2072 @@ +package net.linkedcorp.linkedsign.ui + +import android.app.DownloadManager +import android.content.Context +import android.content.Intent +import android.graphics.Bitmap +import android.graphics.pdf.PdfRenderer +import android.hardware.biometrics.BiometricPrompt +import android.net.Uri +import android.os.Build +import android.os.CancellationSignal +import android.os.Environment +import android.os.ParcelFileDescriptor +import android.widget.Toast +import androidx.activity.ComponentActivity +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.biometric.BiometricManager +import androidx.core.content.ContextCompat +import androidx.core.content.FileProvider +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.AccountCircle +import androidx.compose.material.icons.outlined.Article +import androidx.compose.material.icons.outlined.Badge +import androidx.compose.material.icons.outlined.CheckCircle +import androidx.compose.material.icons.outlined.Dashboard +import androidx.compose.material.icons.outlined.Description +import androidx.compose.material.icons.outlined.Download +import androidx.compose.material.icons.outlined.Draw +import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material.icons.outlined.Fingerprint +import androidx.compose.material.icons.outlined.FolderOpen +import androidx.compose.material.icons.outlined.Lock +import androidx.compose.material.icons.outlined.Logout +import androidx.compose.material.icons.outlined.PictureAsPdf +import androidx.compose.material.icons.outlined.Sync +import androidx.compose.material.icons.outlined.Verified +import androidx.compose.material.icons.outlined.Visibility +import androidx.compose.material.icons.outlined.VisibilityOff +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.AssistChip +import androidx.compose.material3.AssistChipDefaults +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Divider +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.NavigationBarItemDefaults +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.IconButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.foundation.layout.ColumnScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import net.linkedcorp.linkedsign.BuildConfig +import net.linkedcorp.linkedsign.data.AppException +import net.linkedcorp.linkedsign.data.CertificateItem +import net.linkedcorp.linkedsign.data.SignatureRect +import net.linkedcorp.linkedsign.ui.theme.AppFontSizes +import okhttp3.OkHttpClient +import okhttp3.Request +import java.io.File +import net.linkedcorp.linkedsign.data.DocumentItem +import net.linkedcorp.linkedsign.data.SimpleMessageData +import kotlin.math.roundToInt +import net.linkedcorp.linkedsign.data.SignedDocumentItem + +private val Brand = Color(0xFF1565C0) +private val BrandDark = Color(0xFF1A1A2E) +private val BrandDeep = Color(0xFF0D47A1) +private val BrandLight = Color(0xFFE3F2FD) +private val SurfaceSoft = Color(0xFFF0F4FF) +private val SurfaceAlt = Color(0xFFFFFFFF) +private val StrokeSoft = Color(0xFFC5D0E8) +private val TextMuted = Color(0xFF6B7A99) +private val Soft = Color(0xFFF6F8FD) +private val Success = Color(0xFF16A34A) +private val SuccessBg = Color(0xFFDCFCE7) +private val Warning = Color(0xFFD97706) +private val WarningBg = Color(0xFFFEF3C7) +private val Danger = Color(0xFFDC2626) + +private val BrandGradient = Brush.verticalGradient( + listOf(Brand, BrandDeep) +) +private val HeroGradient = Brush.verticalGradient( + listOf(Brand, BrandDeep) +) +private val CardGlow = Brush.linearGradient( + listOf(Color(0x121565C0), Color(0x04FFFFFF)) +) + +private const val SignatureHeight = 60 + +private enum class AuthMode { Login, Register, VerifyNotice } +private enum class AppTab(val title: String) { Dashboard("Inicio"), Documents("Documentos"), Sign("Firmar"), Certificates("Certificados"), Profile("Perfil") } + +private data class PdfPreviewState( + val file: File, + val bitmap: androidx.compose.ui.graphics.ImageBitmap, + val pageIndex: Int, + val pageCount: Int, + val pageWidth: Int, + val pageHeight: Int +) + +private data class SignaturePlacement( + val page: Int, + val rect: SignatureRect, + val previewX: Float, + val previewY: Float, + val previewWidth: Float, + val previewHeight: Float +) + +private data class UnifiedDocumentItem( + val documentId: Int, + val title: String, + val subtitle: String, + val originalFilename: String, + val signedSignatureId: Int? = null, + val signedFilename: String? = null, + val isSigned: Boolean = false +) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun LinkedSignApp(viewModel: MainViewModel, appContext: Context, activity: ComponentActivity) { + val scope = rememberCoroutineScope() + val snackbarHostState = remember { SnackbarHostState() } + val state = viewModel.uiState + + var authMode by rememberSaveable { mutableStateOf(AuthMode.Login) } + var selectedTab by rememberSaveable { mutableStateOf(AppTab.Dashboard) } + var pendingSignDocumentId by rememberSaveable { mutableStateOf(null) } + val biometricAvailable = remember(activity) { canUseBiometricAccess(activity) } + var appUnlocked by rememberSaveable { mutableStateOf(false) } + var bootstrapGateHandled by rememberSaveable { mutableStateOf(false) } + var pendingBiometricEmail by rememberSaveable { mutableStateOf(null) } + var pendingBiometricPassword by rememberSaveable { mutableStateOf(null) } + var showBiometricOptIn by rememberSaveable { mutableStateOf(false) } + + LaunchedEffect(Unit) { + viewModel.bootstrap() + } + + LaunchedEffect(state.emailForVerificationNotice) { + if (!state.emailForVerificationNotice.isNullOrBlank()) { + authMode = AuthMode.VerifyNotice + } + } + + LaunchedEffect(state.loading, state.authenticated, biometricAvailable) { + if (!state.loading && !bootstrapGateHandled) { + bootstrapGateHandled = true + appUnlocked = !(biometricAvailable && state.biometricEnabled && state.biometricHasCredentials) + } + + if (!state.authenticated && !(state.biometricEnabled && state.biometricHasCredentials)) { + appUnlocked = true + } + } + + Scaffold( + snackbarHost = { SnackbarHost(snackbarHostState) }, + containerColor = SurfaceSoft, + topBar = { + if (state.authenticated) { + TopAppBar( + title = { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Box( + modifier = Modifier + .size(32.dp) + .background(BrandGradient, CircleShape), + contentAlignment = Alignment.Center + ) { + Text("LS", color = Color.White, fontSize = AppFontSizes.xs, fontWeight = FontWeight.Bold) + } + Column(verticalArrangement = Arrangement.spacedBy(1.dp)) { + Text(selectedTab.title, fontWeight = FontWeight.SemiBold, color = Color.White) + Text("Firma electrónica", fontSize = AppFontSizes.xs, color = Color(0xFF90CAF9)) + } + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = Brand, + titleContentColor = Color.White + ) + ) + } + }, + bottomBar = { + if (state.authenticated) { + NavigationBar( + containerColor = Color.White, + tonalElevation = 0.dp + ) { + val tabs = listOf( + AppTab.Dashboard to Icons.Outlined.Dashboard, + AppTab.Documents to Icons.Outlined.Description, + AppTab.Sign to Icons.Outlined.Draw, + AppTab.Certificates to Icons.Outlined.Badge, + AppTab.Profile to Icons.Outlined.AccountCircle + ) + tabs.forEach { (tab, icon) -> + NavigationBarItem( + selected = selectedTab == tab, + onClick = { selectedTab = tab }, + icon = { Icon(icon, contentDescription = tab.title) }, + label = null, + alwaysShowLabel = false, + colors = NavigationBarItemDefaults.colors( + selectedIconColor = Brand, + selectedTextColor = Brand, + unselectedIconColor = TextMuted, + unselectedTextColor = TextMuted, + indicatorColor = Color.Transparent + ) + ) + } + } + } + } + ) { padding -> + when { + state.loading -> LoadingState() + biometricAvailable && state.biometricEnabled && state.biometricHasCredentials && !appUnlocked -> BiometricUnlockScreen( + modifier = Modifier.padding(padding), + email = state.biometricEmail ?: state.userEmail, + onUnlock = { + requestBiometricUnlock( + activity = activity, + onSuccess = { + if (state.authenticated) { + appUnlocked = true + scope.launch { snackbarHostState.showSnackbar("Acceso biométrico confirmado.") } + } else { + scope.launch { + runCatching { viewModel.biometricLogin() } + .onSuccess { + selectedTab = AppTab.Dashboard + appUnlocked = true + snackbarHostState.showSnackbar("Sesión iniciada con huella.") + } + .onFailure { error -> + snackbarHostState.showSnackbar((error as? AppException)?.message ?: "No se pudo iniciar con huella.") + } + } + } + }, + onError = { message -> + scope.launch { snackbarHostState.showSnackbar(message) } + } + ) + }, + onContinueWithoutBiometric = { + if (state.authenticated) { + appUnlocked = true + } else { + authMode = AuthMode.Login + viewModel.declineBiometricAccess() + appUnlocked = true + } + }, + onLogout = { + scope.launch { + viewModel.logout() + authMode = AuthMode.Login + pendingSignDocumentId = null + appUnlocked = true + snackbarHostState.showSnackbar("Sesión cerrada.") + } + } + ) + !state.authenticated -> AuthFlow( + mode = authMode, + onModeChange = { authMode = it }, + verifyEmail = state.emailForVerificationNotice, + onConsumeVerifyNotice = { viewModel.clearVerificationNotice() }, + onLogin = { email, password -> + scope.launch { + runCatching { viewModel.login(email, password) } + .onSuccess { + selectedTab = AppTab.Dashboard + appUnlocked = true + pendingBiometricEmail = email.trim() + pendingBiometricPassword = password + showBiometricOptIn = biometricAvailable && !state.biometricConfigured + snackbarHostState.showSnackbar("Sesión iniciada.") + } + .onFailure { error -> + val message = (error as? AppException)?.message ?: "No se pudo iniciar sesión." + if ((error as? AppException)?.code == "EMAIL_NOT_VERIFIED") { + snackbarHostState.showSnackbar("Tu correo aún no está validado. Se envió un nuevo enlace.") + } else { + snackbarHostState.showSnackbar(message) + } + } + } + }, + onRegister = { email, password -> + scope.launch { + runCatching { viewModel.register(email, password) } + .onSuccess { + authMode = AuthMode.VerifyNotice + } + .onFailure { error -> + snackbarHostState.showSnackbar((error as? AppException)?.message ?: "No se pudo registrar.") + } + } + }, + modifier = Modifier.padding(padding) + ) + else -> AppFlow( + padding = padding, + selectedTab = selectedTab, + state = state, + appContext = appContext, + onSync = { + scope.launch { + runCatching { viewModel.syncNow() } + .onSuccess { snackbarHostState.showSnackbar("Sincronización completada.") } + .onFailure { snackbarHostState.showSnackbar((it as? AppException)?.message ?: "No se pudo sincronizar.") } + } + }, + onUploadDocument = { uri, name -> + scope.launch { + runCatching { viewModel.uploadDocument(uri, name) } + .onSuccess { snackbarHostState.showSnackbar("Documento subido.") } + .onFailure { snackbarHostState.showSnackbar((it as? AppException)?.message ?: "No se pudo subir el documento.") } + } + }, + onUploadCertificate = { uri, name, alias, password -> + scope.launch { + runCatching { viewModel.uploadCertificate(uri, name, alias, password) } + .onSuccess { snackbarHostState.showSnackbar("Certificado subido.") } + .onFailure { snackbarHostState.showSnackbar((it as? AppException)?.message ?: "No se pudo subir el certificado.") } + } + }, + onDeleteDocument = { item -> + scope.launch { + runCatching { + if (item.signedSignatureId != null) { + viewModel.deleteSignedDocument(item.signedSignatureId) + } else { + viewModel.deleteDocument(item.documentId) + } + }.onSuccess { + snackbarHostState.showSnackbar("Archivo eliminado.") + }.onFailure { + snackbarHostState.showSnackbar((it as? AppException)?.message ?: "No se pudo eliminar el archivo.") + } + } + }, + onSign = { documentId, certificateId, certPassword, reason, location, page, rect -> + scope.launch { + runCatching { viewModel.signDocument(documentId, certificateId, certPassword, reason, location, page, rect) } + .onSuccess { + selectedTab = AppTab.Documents + pendingSignDocumentId = null + appUnlocked = true + snackbarHostState.showSnackbar("Documento firmado.") + } + .onFailure { snackbarHostState.showSnackbar((it as? AppException)?.message ?: "No se pudo firmar el documento.") } + } + }, + onRequestPasswordChange = { currentPassword, newPassword -> + scope.launch { + runCatching { viewModel.requestPasswordChange(currentPassword, newPassword) } + .onSuccess { snackbarHostState.showSnackbar("Te enviamos un correo para confirmar el cambio de contraseña.") } + .onFailure { snackbarHostState.showSnackbar((it as? AppException)?.message ?: "No se pudo solicitar el cambio.") } + } + }, + onLogout = { + scope.launch { + viewModel.logout() + authMode = AuthMode.Login + pendingSignDocumentId = null + appUnlocked = !(biometricAvailable && state.biometricEnabled && state.biometricHasCredentials) + snackbarHostState.showSnackbar("Sesión cerrada.") + } + }, + selectedSignDocumentId = pendingSignDocumentId, + onSelectDocumentToSign = { documentId -> + pendingSignDocumentId = documentId.toString() + selectedTab = AppTab.Sign + }, + accessToken = { viewModel.accessToken() } + ) + } + } + + if (showBiometricOptIn && pendingBiometricEmail != null && pendingBiometricPassword != null) { + AlertDialog( + onDismissRequest = { + viewModel.declineBiometricAccess() + showBiometricOptIn = false + pendingBiometricEmail = null + pendingBiometricPassword = null + }, + title = { Text("Activar acceso biométrico") }, + text = { + Text("¿Deseas usar huella para acceder en adelante? Solo esta primera vez se te pedirán las credenciales.") + }, + confirmButton = { + Button(onClick = { + viewModel.enableBiometricAccess( + pendingBiometricEmail.orEmpty(), + pendingBiometricPassword.orEmpty() + ) + showBiometricOptIn = false + pendingBiometricEmail = null + pendingBiometricPassword = null + }) { + Text("Sí, usar huella") + } + }, + dismissButton = { + OutlinedButton(onClick = { + viewModel.declineBiometricAccess() + showBiometricOptIn = false + pendingBiometricEmail = null + pendingBiometricPassword = null + }) { + Text("Ahora no") + } + } + ) + } +} + +@Composable +private fun LoadingState() { + Box( + modifier = Modifier + .fillMaxSize() + .background(HeroGradient), + contentAlignment = Alignment.Center + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(20.dp)) { + Box( + modifier = Modifier + .size(80.dp) + .background(Color.White.copy(alpha = 0.12f), CircleShape), + contentAlignment = Alignment.Center + ) { + Text("LS", color = Color.White, fontSize = AppFontSizes.display, fontWeight = FontWeight.Bold) + } + CircularProgressIndicator(color = Color.White, strokeWidth = 2.5.dp, modifier = Modifier.size(32.dp)) + Text("Cargando LinkedSign...", color = Color.White.copy(alpha = 0.8f), fontSize = AppFontSizes.lg) + } + } +} + +@Composable +private fun AuthFlow( + mode: AuthMode, + onModeChange: (AuthMode) -> Unit, + verifyEmail: String?, + onConsumeVerifyNotice: () -> Unit, + onLogin: (String, String) -> Unit, + onRegister: (String, String) -> Unit, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier.fillMaxSize() + ) { + // Hero header + Box( + modifier = Modifier + .fillMaxWidth() + .height(200.dp) + .background(HeroGradient), + contentAlignment = Alignment.Center + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(10.dp)) { + Box( + modifier = Modifier + .size(64.dp) + .background(Color.White.copy(alpha = 0.15f), CircleShape), + contentAlignment = Alignment.Center + ) { + Text("LS", color = Color.White, fontSize = AppFontSizes.hero, fontWeight = FontWeight.Bold) + } + Text("LinkedSign", color = Color.White, fontSize = AppFontSizes.brand, fontWeight = FontWeight.Bold) + Text( + if (mode == AuthMode.Register) "Crear cuenta" else "Firma digital profesional", + color = Color.White.copy(alpha = 0.75f), + fontSize = AppFontSizes.base + ) + } + } + + // Form area + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 20.dp, vertical = 24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + when (mode) { + AuthMode.Login -> LoginCard( + onSubmit = onLogin, + onGoRegister = { onModeChange(AuthMode.Register) } + ) + AuthMode.Register -> RegisterCard( + onSubmit = onRegister, + onGoLogin = { onModeChange(AuthMode.Login) } + ) + AuthMode.VerifyNotice -> VerifyNoticeCard( + email = verifyEmail, + onBackToLogin = { + onConsumeVerifyNotice() + onModeChange(AuthMode.Login) + } + ) + } + Text( + "Servidor: ${BuildConfig.API_BASE_URL}", + color = Color(0xFF9CA3AF), + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.align(Alignment.CenterHorizontally) + ) + } + } +} + +@Composable +private fun BiometricUnlockScreen( + email: String?, + onUnlock: () -> Unit, + onContinueWithoutBiometric: () -> Unit, + onLogout: () -> Unit, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + .fillMaxSize() + .background(SurfaceSoft) + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .background(BrandDeep) + .padding(horizontal = 20.dp, vertical = 18.dp) + ) { + Text("Verificar identidad", color = Color.White, fontSize = AppFontSizes.title, fontWeight = FontWeight.SemiBold) + } + Column( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Surface( + shape = RoundedCornerShape(18.dp), + color = Color.White, + border = BorderStroke(1.dp, StrokeSoft) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + Box( + modifier = Modifier + .size(88.dp) + .background(Color(0xFFF6FAFF), CircleShape), + contentAlignment = Alignment.Center + ) { + Icon(Icons.Outlined.Fingerprint, contentDescription = null, tint = Brand, modifier = Modifier.size(54.dp)) + } + Text("Huella dactilar", color = BrandDark, fontSize = AppFontSizes.xl, fontWeight = FontWeight.SemiBold) + Text("Coloque su dedo en el sensor", color = TextMuted, fontSize = AppFontSizes.base) + if (!email.isNullOrBlank()) { + Text(email, color = TextMuted, fontSize = AppFontSizes.sm) + } + } + } + Text("o use", color = TextMuted, fontSize = AppFontSizes.xs, fontWeight = FontWeight.SemiBold) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + AltAuthTile("Face ID") + AltAuthTile("PIN") + AltAuthTile("SMS OTP") + } + FilledActionButton("Acceder con huella", Icons.Outlined.Fingerprint, onClick = onUnlock) + OutlinedActionButton("Continuar sin huella", Icons.Outlined.Lock, onClick = onContinueWithoutBiometric) + TextButton(onClick = onLogout) { + Icon(Icons.Outlined.Logout, contentDescription = null, modifier = Modifier.size(16.dp), tint = Danger) + Spacer(Modifier.width(6.dp)) + Text("Cerrar sesión", color = Danger) + } + } + } +} + +@Composable +private fun AppFlow( + padding: PaddingValues, + selectedTab: AppTab, + state: MainUiState, + appContext: Context, + selectedSignDocumentId: String?, + onSync: () -> Unit, + onUploadDocument: (Uri, String) -> Unit, + onUploadCertificate: (Uri, String, String, String) -> Unit, + onDeleteDocument: (UnifiedDocumentItem) -> Unit, + onSign: (Int, Int, String, String, String, Int, SignatureRect?) -> Unit, + onRequestPasswordChange: (String, String) -> Unit, + onLogout: () -> Unit, + onSelectDocumentToSign: (Int) -> Unit, + accessToken: () -> String? +) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(padding) + ) { + when (selectedTab) { + AppTab.Dashboard -> DashboardScreen(state = state, onSync = onSync) + AppTab.Documents -> DocumentsScreen( + state = state, + accessToken = accessToken, + appContext = appContext, + onSync = onSync, + onUploadDocument = onUploadDocument, + onSelectDocumentToSign = onSelectDocumentToSign, + onDeleteDocument = onDeleteDocument + ) + AppTab.Sign -> SignScreen( + state = state, + accessToken = accessToken, + appContext = appContext, + initialDocumentId = selectedSignDocumentId, + onSign = onSign + ) + AppTab.Certificates -> CertificatesScreen(state = state, onSync = onSync, onUploadCertificate = onUploadCertificate) + AppTab.Profile -> ProfileScreen(state = state, onRequestPasswordChange = onRequestPasswordChange, onLogout = onLogout, onSync = onSync) + } + } +} + +@Composable +private fun LoginCard(onSubmit: (String, String) -> Unit, onGoRegister: () -> Unit) { + var email by rememberSaveable { mutableStateOf("") } + var password by rememberSaveable { mutableStateOf("") } + + ElevatedCard( + shape = RoundedCornerShape(24.dp), + elevation = CardDefaults.elevatedCardElevation(defaultElevation = 8.dp), + colors = CardDefaults.elevatedCardColors(containerColor = Color.White) + ) { + Column(modifier = Modifier.padding(24.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) { + Surface( + color = BrandLight, + shape = RoundedCornerShape(16.dp) + ) { + Row( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + Box( + modifier = Modifier.size(32.dp).background(BrandGradient, RoundedCornerShape(12.dp)), + contentAlignment = Alignment.Center + ) { + Icon(Icons.Outlined.Lock, contentDescription = null, tint = Color.White, modifier = Modifier.size(18.dp)) + } + Column { + Text("Acceso seguro", color = BrandDark, fontWeight = FontWeight.SemiBold, fontSize = AppFontSizes.base) + Text("Tu firma digital y documentos en un solo lugar", color = TextMuted, fontSize = AppFontSizes.sm) + } + } + } + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text("Iniciar sesión", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, color = BrandDark) + Text("Accede a tu cuenta LinkedSign", color = TextMuted, style = MaterialTheme.typography.bodySmall) + } + AppTextField(value = email, onValueChange = { email = it }, label = "Correo electrónico", keyboardType = KeyboardType.Email) + AppTextField(value = password, onValueChange = { password = it }, label = "Contraseña", isPassword = true) + Spacer(Modifier.height(2.dp)) + Button( + onClick = { onSubmit(email, password) }, + modifier = Modifier.fillMaxWidth().height(50.dp), + shape = RoundedCornerShape(14.dp), + colors = ButtonDefaults.buttonColors(containerColor = Brand) + ) { + Icon(Icons.Outlined.Lock, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + Text("Entrar", fontWeight = FontWeight.SemiBold, fontSize = AppFontSizes.xl) + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + Text("¿No tienes cuenta?", color = Color(0xFF6B7280), fontSize = AppFontSizes.base) + TextButton(onClick = onGoRegister, contentPadding = PaddingValues(horizontal = 6.dp)) { + Text("Regístrate", color = Brand, fontWeight = FontWeight.SemiBold, fontSize = AppFontSizes.base) + } + } + } + } +} + +@Composable +private fun RegisterCard(onSubmit: (String, String) -> Unit, onGoLogin: () -> Unit) { + var email by rememberSaveable { mutableStateOf("") } + var password by rememberSaveable { mutableStateOf("") } + var confirmPassword by rememberSaveable { mutableStateOf("") } + val passwordProblems = passwordIssues(password) + + ElevatedCard( + shape = RoundedCornerShape(24.dp), + elevation = CardDefaults.elevatedCardElevation(defaultElevation = 8.dp), + colors = CardDefaults.elevatedCardColors(containerColor = Color.White) + ) { + Column(modifier = Modifier.padding(24.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) { + Surface( + color = SurfaceAlt, + shape = RoundedCornerShape(16.dp), + border = BorderStroke(1.dp, BrandLight) + ) { + Row( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + Box( + modifier = Modifier.size(32.dp).background(Color.White, RoundedCornerShape(12.dp)), + contentAlignment = Alignment.Center + ) { + Icon(Icons.Outlined.Verified, contentDescription = null, tint = Brand, modifier = Modifier.size(18.dp)) + } + Column { + Text("Alta protegida", color = BrandDark, fontWeight = FontWeight.SemiBold, fontSize = AppFontSizes.base) + Text("Usa un correo válido y una clave fuerte", color = TextMuted, fontSize = AppFontSizes.sm) + } + } + } + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text("Crear cuenta", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, color = BrandDark) + Text("Registra tu cuenta en LinkedSign", color = TextMuted, style = MaterialTheme.typography.bodySmall) + } + AppTextField(value = email, onValueChange = { email = it }, label = "Correo electrónico", keyboardType = KeyboardType.Email) + AppTextField(value = password, onValueChange = { password = it }, label = "Contraseña", isPassword = true) + PasswordChecklist(password = password) + AppTextField(value = confirmPassword, onValueChange = { confirmPassword = it }, label = "Confirmar contraseña", isPassword = true) + if (passwordProblems.isNotEmpty() && password.isNotBlank()) { + Surface(color = WarningBg, shape = RoundedCornerShape(10.dp)) { + Text("La contraseña aún no cumple todos los requisitos.", color = Warning, modifier = Modifier.padding(10.dp), fontSize = AppFontSizes.md) + } + } + if (password != confirmPassword && confirmPassword.isNotBlank()) { + Surface(color = Color(0xFFFEE2E2), shape = RoundedCornerShape(10.dp)) { + Text("Las contraseñas no coinciden.", color = Danger, modifier = Modifier.padding(10.dp), fontSize = AppFontSizes.md) + } + } + Spacer(Modifier.height(2.dp)) + Button( + onClick = { + when { + passwordProblems.isNotEmpty() -> Unit + password != confirmPassword -> Unit + else -> onSubmit(email, password) + } + }, + modifier = Modifier.fillMaxWidth().height(50.dp), + shape = RoundedCornerShape(14.dp), + colors = ButtonDefaults.buttonColors(containerColor = Brand) + ) { + Text("Crear cuenta", fontWeight = FontWeight.SemiBold, fontSize = AppFontSizes.xl) + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + Text("¿Ya tienes cuenta?", color = Color(0xFF6B7280), fontSize = AppFontSizes.base) + TextButton(onClick = onGoLogin, contentPadding = PaddingValues(horizontal = 6.dp)) { + Text("Inicia sesión", color = Brand, fontWeight = FontWeight.SemiBold, fontSize = AppFontSizes.base) + } + } + } + } +} + +@Composable +private fun VerifyNoticeCard(email: String?, onBackToLogin: () -> Unit) { + ElevatedCard( + shape = RoundedCornerShape(24.dp), + elevation = CardDefaults.elevatedCardElevation(defaultElevation = 8.dp), + colors = CardDefaults.elevatedCardColors(containerColor = Color.White) + ) { + Column(modifier = Modifier.padding(24.dp), verticalArrangement = Arrangement.spacedBy(16.dp), horizontalAlignment = Alignment.CenterHorizontally) { + Box( + modifier = Modifier + .size(64.dp) + .background(Brush.radialGradient(listOf(SuccessBg, Color.White)), CircleShape), + contentAlignment = Alignment.Center + ) { + Icon(Icons.Outlined.CheckCircle, contentDescription = null, tint = Success, modifier = Modifier.size(32.dp)) + } + Text("Confirma tu cuenta", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, color = BrandDark) + Text( + "Enviamos un enlace de verificación a ${email ?: "tu correo"}. Debes confirmarlo antes de iniciar sesión.", + color = TextMuted, + style = MaterialTheme.typography.bodyMedium + ) + Button( + onClick = onBackToLogin, + modifier = Modifier.fillMaxWidth().height(50.dp), + shape = RoundedCornerShape(14.dp), + colors = ButtonDefaults.buttonColors(containerColor = Brand) + ) { + Text("Volver al inicio", fontWeight = FontWeight.SemiBold) + } + } + } +} + +@Composable +private fun DashboardScreen(state: MainUiState, onSync: () -> Unit) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(bottom = 24.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + item { + Box( + modifier = Modifier + .fillMaxWidth() + .background(Brand) + .padding(horizontal = 20.dp, vertical = 20.dp) + ) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Box( + modifier = Modifier.size(44.dp).background(Color.White.copy(alpha = 0.2f), CircleShape), + contentAlignment = Alignment.Center + ) { + Text( + (state.userName ?: state.userEmail ?: "U").take(1).uppercase(), + color = Color.White, fontSize = AppFontSizes.title, fontWeight = FontWeight.Bold + ) + } + Column { + Text( + "Hola, ${(state.userName ?: state.userEmail ?: "usuario").split(" ").first()}", + color = Color.White, + fontSize = AppFontSizes.title, + fontWeight = FontWeight.SemiBold + ) + Text( + state.userEmail ?: "", + color = Color(0xFF90CAF9), + fontSize = AppFontSizes.sm + ) + } + } + Spacer(Modifier.height(4.dp)) + Surface(color = Color.White.copy(alpha = 0.12f), shape = RoundedCornerShape(14.dp)) { + Text( + "Los datos se sincronizan cuando tú lo solicitas", + color = Color.White.copy(alpha = 0.9f), + fontSize = AppFontSizes.sm, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 7.dp) + ) + } + } + } + } + + item { + Row( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + StatCard("Documentos", state.documents.size.toString(), Icons.Outlined.Description, Brand, Modifier.weight(1f)) + StatCard("Certificados", state.certificates.size.toString(), Icons.Outlined.Badge, Color(0xFF0F766E), Modifier.weight(1f)) + StatCard("Firmados", state.signedDocuments.size.toString(), Icons.Outlined.Verified, Success, Modifier.weight(1f)) + } + } + + item { + Column(modifier = Modifier.padding(horizontal = 16.dp)) { + SectionCard(title = "Sincronización") { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Box( + modifier = Modifier.size(8.dp).background( + if (state.lastSyncAt != null) Success else Color(0xFFD1D5DB), + CircleShape + ) + ) + Text( + "Última: ${state.lastSyncAt ?: "Sin sincronizar"}", + color = Color(0xFF6B7280), + style = MaterialTheme.typography.bodySmall + ) + } + Spacer(modifier = Modifier.height(4.dp)) + FilledActionButton( + label = if (state.syncing) "Sincronizando..." else "Sincronizar ahora", + icon = Icons.Outlined.Sync, + enabled = !state.syncing, + onClick = onSync + ) + } + } + } + } +} + +@Composable +private fun DocumentsScreen( + state: MainUiState, + accessToken: () -> String?, + appContext: Context, + onSync: () -> Unit, + onUploadDocument: (Uri, String) -> Unit, + onSelectDocumentToSign: (Int) -> Unit, + onDeleteDocument: (UnifiedDocumentItem) -> Unit +) { + val scope = rememberCoroutineScope() + var deleteTarget by remember { mutableStateOf(null) } + val launcher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> + uri ?: return@rememberLauncherForActivityResult + try { + appContext.contentResolver.takePersistableUriPermission( + uri, + Intent.FLAG_GRANT_READ_URI_PERMISSION + ) + } catch (_: SecurityException) { + } catch (_: Exception) { + } + val name = queryFileName(appContext, uri) ?: "documento.pdf" + onUploadDocument(uri, name) + } + + val signedByDocumentId = state.signedDocuments.associateBy { it.documentId } + val unifiedDocuments = state.documents.map { document -> + val signed = signedByDocumentId[document.id] + val isSignedDocument = signed != null || document.status.equals("signed", ignoreCase = true) + UnifiedDocumentItem( + documentId = document.id, + title = document.title ?: document.filename ?: "Documento", + subtitle = if (isSignedDocument) { + signed?.signedAt ?: "PDF firmado" + } else { + document.status ?: "PDF original" + }, + originalFilename = document.filename ?: "${document.title ?: "documento"}.pdf", + signedSignatureId = signed?.id, + signedFilename = signed?.signedFilename, + isSigned = isSignedDocument + ) + } + + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + item { + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + FilledActionButton("Subir documento", Icons.Outlined.Article, modifier = Modifier.weight(1f)) { + launcher.launch(arrayOf("application/pdf")) + } + OutlinedActionButton("Actualizar", Icons.Outlined.Sync, modifier = Modifier.weight(1f), onClick = onSync) + } + } + item { + CompactSectionLabel("MIS DOCUMENTOS") + SectionCard(title = "Documentos") { + if (unifiedDocuments.isEmpty()) { + Text("No hay documentos sincronizados.", color = Color(0xFF6B7280)) + } else { + unifiedDocuments.forEach { item -> + DocumentRow( + title = item.title, + subtitle = item.subtitle, + onSelect = if (item.isSigned) null else ({ onSelectDocumentToSign(item.documentId) }), + onView = { + scope.launch { + runCatching { + if (item.isSigned && item.signedSignatureId != null) { + openSecurePdf( + appContext, + "${BuildConfig.API_BASE_URL}/signatures/${item.signedSignatureId}/download", + accessToken(), + item.signedFilename ?: "firmado.pdf" + ) + } else { + openSecurePdf( + appContext, + "${BuildConfig.API_BASE_URL}/documents/${item.documentId}/download", + accessToken(), + item.originalFilename + ) + } + }.onFailure { + Toast.makeText(appContext, it.message ?: "No se pudo abrir el PDF.", Toast.LENGTH_SHORT).show() + } + } + }, + onDownload = { + if (item.isSigned && item.signedSignatureId != null) { + downloadFile( + appContext, + "${BuildConfig.API_BASE_URL}/signatures/${item.signedSignatureId}/download", + accessToken(), + item.signedFilename ?: "firmado.pdf" + ) + } else { + downloadFile( + appContext, + "${BuildConfig.API_BASE_URL}/documents/${item.documentId}/download", + accessToken(), + item.originalFilename + ) + } + }, + onDelete = { deleteTarget = item } + ) + } + } + } + } + } + + if (deleteTarget != null) { + AlertDialog( + onDismissRequest = { deleteTarget = null }, + confirmButton = { + Button(onClick = { + onDeleteDocument(deleteTarget!!) + deleteTarget = null + }) { + Text("Eliminar") + } + }, + dismissButton = { + OutlinedButton(onClick = { deleteTarget = null }) { + Text("Cancelar") + } + }, + title = { Text("Eliminar archivo") }, + text = { + Text( + if (deleteTarget!!.isSigned) { + "Se eliminarán el documento firmado y el original." + } else { + "Se eliminará el documento original." + } + ) + } + ) + } +} + +@Composable +private fun CertificatesScreen( + state: MainUiState, + onSync: () -> Unit, + onUploadCertificate: (Uri, String, String, String) -> Unit +) { + var alias by rememberSaveable { mutableStateOf("") } + var password by rememberSaveable { mutableStateOf("") } + val context = LocalContext.current + val launcher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> + uri ?: return@rememberLauncherForActivityResult + val name = queryFileName(context, uri) ?: "certificado.p12" + onUploadCertificate(uri, name, alias.ifBlank { name }, password) + } + + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + item { + SectionCard(title = "Subir certificado") { + AppTextField(value = alias, onValueChange = { alias = it }, label = "Alias") + AppTextField(value = password, onValueChange = { password = it }, label = "Contraseña del P12/PFX", isPassword = true) + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + FilledActionButton("Seleccionar", Icons.Outlined.Badge, modifier = Modifier.weight(1f)) { + launcher.launch(arrayOf("*/*")) + } + OutlinedActionButton("Sincronizar", Icons.Outlined.Sync, modifier = Modifier.weight(1f), onClick = onSync) + } + } + } + item { + SectionCard(title = "Certificados sincronizados") { + if (state.certificates.isEmpty()) { + Text("No hay certificados sincronizados.", color = Color(0xFF6B7280)) + } else { + state.certificates.forEach { certificate -> + CertificateRow(certificate) + } + } + } + } + } +} + +@Composable +private fun SignScreen( + state: MainUiState, + accessToken: () -> String?, + appContext: Context, + initialDocumentId: String?, + onSign: (Int, Int, String, String, String, Int, SignatureRect?) -> Unit +) { + val activeCertificates = state.certificates.filter { (it.isActive ?: 0) == 1 } + var selectedDocumentId by rememberSaveable(state.documents) { mutableStateOf(state.documents.firstOrNull()?.id?.toString().orEmpty()) } + var selectedCertificateId by rememberSaveable(activeCertificates) { mutableStateOf(activeCertificates.firstOrNull()?.id?.toString().orEmpty()) } + var certPassword by rememberSaveable { mutableStateOf("") } + var reason by rememberSaveable { mutableStateOf("Firmado digitalmente") } + var location by rememberSaveable { mutableStateOf("") } + var currentPageIndex by rememberSaveable(selectedDocumentId) { mutableStateOf(0) } + var preview by remember(selectedDocumentId) { mutableStateOf(null) } + var placement by remember(selectedDocumentId) { mutableStateOf(null) } + var previewSize by remember { mutableStateOf(IntSize.Zero) } + val scope = rememberCoroutineScope() + val density = LocalDensity.current + val selectedDocument = state.documents.firstOrNull { it.id.toString() == selectedDocumentId } + val signerLabel = state.userName ?: state.userEmail ?: "Firmante" + val signatureStyle = state.signaturePreferences?.signatureStyle ?: "qr" + + LaunchedEffect(initialDocumentId, state.documents) { + val documentId = initialDocumentId ?: return@LaunchedEffect + if (state.documents.any { it.id.toString() == documentId } && selectedDocumentId != documentId) { + selectedDocumentId = documentId + } + } + + LaunchedEffect(selectedDocumentId) { + currentPageIndex = 0 + preview = null + placement = null + } + + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + item { + SectionCard(title = "Vista previa y firma") { + Text("Esta versión usa la configuración visual de firma ya guardada en tu cuenta.", color = TextMuted, fontSize = AppFontSizes.sm) + DropdownSelector( + label = "Documento", + options = state.documents.associate { it.id.toString() to (it.title ?: it.filename ?: "Documento ${it.id}") }, + selectedValue = selectedDocumentId, + onSelect = { selectedDocumentId = it } + ) + DropdownSelector( + label = "Certificado", + options = activeCertificates.associate { it.id.toString() to (it.alias ?: it.subjectCn ?: "Certificado ${it.id}") }, + selectedValue = selectedCertificateId, + onSelect = { selectedCertificateId = it } + ) + AppTextField(value = certPassword, onValueChange = { certPassword = it }, label = "Contraseña del certificado", isPassword = true) + AppTextField(value = reason, onValueChange = { reason = it }, label = "Razón") + AppTextField(value = location, onValueChange = { location = it }, label = "Ubicación") + OutlinedActionButton("Cargar PDF para ubicar firma", Icons.Outlined.Description) { + val documentId = selectedDocumentId.toIntOrNull() ?: return@OutlinedActionButton + scope.launch { + runCatching { + val file = downloadSecuredFileToCache( + context = appContext, + url = "${BuildConfig.API_BASE_URL}/documents/$documentId/download", + accessToken = accessToken(), + fileName = selectedDocument?.filename ?: "${selectedDocument?.title ?: "documento"}.pdf" + ) + renderPdfPreview(file, currentPageIndex) + }.onSuccess { + preview = it + placement = null + }.onFailure { + Toast.makeText(appContext, it.message ?: "No se pudo cargar el PDF.", Toast.LENGTH_SHORT).show() + } + } + } + val currentPreview = preview + if (currentPreview != null) { + Row(horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically) { + OutlinedActionButton("‹", Icons.Outlined.Description, modifier = Modifier.weight(1f)) { + if (currentPageIndex > 0) { + scope.launch { + runCatching { renderPdfPreview(currentPreview.file, currentPageIndex - 1) } + .onSuccess { + currentPageIndex -= 1 + preview = it + placement = null + } + } + } + } + Text("Página ${currentPageIndex + 1} / ${currentPreview.pageCount}", modifier = Modifier.weight(2f), fontSize = AppFontSizes.base, color = BrandDark) + OutlinedActionButton("›", Icons.Outlined.Description, modifier = Modifier.weight(1f)) { + val maxPages = currentPreview.pageCount + if (currentPageIndex < maxPages - 1) { + scope.launch { + runCatching { renderPdfPreview(currentPreview.file, currentPageIndex + 1) } + .onSuccess { + currentPageIndex += 1 + preview = it + placement = null + } + } + } + } + } + Text("Toca el PDF para definir la esquina superior izquierda de la firma.", color = TextMuted, fontSize = AppFontSizes.sm) + Box( + modifier = Modifier + .fillMaxWidth() + .background(Color(0xFF9E9E9E), RoundedCornerShape(18.dp)) + .padding(10.dp) + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .background(Color.White, RoundedCornerShape(6.dp)) + .onSizeChanged { previewSize = it } + .pointerInput(currentPreview, previewSize) { + detectTapGestures { offset -> + val pageInfo = currentPreview + if (previewSize.width <= 0 || previewSize.height <= 0) return@detectTapGestures + + val signatureWidth = getSignatureWidth(signatureStyle, signerLabel) + val scaleX = previewSize.width.toFloat() / pageInfo.pageWidth.toFloat() + val scaleY = previewSize.height.toFloat() / pageInfo.pageHeight.toFloat() + val previewWidth = signatureWidth * scaleX + val previewHeight = SignatureHeight * scaleY + val maxX = (previewSize.width.toFloat() - previewWidth).coerceAtLeast(0f) + val maxY = (previewSize.height.toFloat() - previewHeight).coerceAtLeast(0f) + val clampedX = offset.x.coerceIn(0f, maxX) + val clampedY = offset.y.coerceIn(0f, maxY) + val pdfX = (clampedX / scaleX).roundToInt() + val pdfY = (pageInfo.pageHeight - ((clampedY / scaleY) + SignatureHeight)).roundToInt() + + placement = SignaturePlacement( + page = currentPageIndex, + rect = SignatureRect( + x = pdfX, + y = pdfY, + width = signatureWidth, + height = SignatureHeight + ), + previewX = clampedX, + previewY = clampedY, + previewWidth = previewWidth, + previewHeight = previewHeight + ) + } + } + ) { + Image( + bitmap = currentPreview.bitmap, + contentDescription = "Vista previa PDF", + modifier = Modifier.fillMaxWidth(), + contentScale = ContentScale.FillWidth + ) + placement?.takeIf { it.page == currentPageIndex }?.let { placed -> + Box( + modifier = Modifier + .padding( + start = with(density) { placed.previewX.toDp() }, + top = with(density) { placed.previewY.toDp() } + ) + .width(with(density) { placed.previewWidth.toDp() }) + .height(with(density) { placed.previewHeight.toDp() }) + .background(Color(0x14000000), RoundedCornerShape(6.dp)) + .clip(RoundedCornerShape(6.dp)) + ) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color(0xFFF0F4FF)) + .padding(8.dp) + ) { + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + text = signerLabel, + color = BrandDark, + fontSize = AppFontSizes.sm, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text("Zona de firma", color = Brand, fontSize = AppFontSizes.xs) + } + } + } + } + } + } + if (placement != null) { + Text( + "Posición confirmada para página ${placement!!.page + 1}: x=${placement!!.rect.x}, y=${placement!!.rect.y}", + color = Brand + ) + } + } + FilledActionButton("Firmar este documento", Icons.Outlined.Draw) { + val documentId = selectedDocumentId.toIntOrNull() + val certificateId = selectedCertificateId.toIntOrNull() + if (placement == null) { + Toast.makeText(appContext, "Carga el PDF y toca dónde quieres ubicar la firma.", Toast.LENGTH_SHORT).show() + } else if (documentId != null && certificateId != null) { + onSign(documentId, certificateId, certPassword, reason, location, placement?.page ?: 0, placement?.rect) + } + } + } + } + } +} + +@Composable +private fun ProfileScreen( + state: MainUiState, + onRequestPasswordChange: (String, String) -> Unit, + onLogout: () -> Unit, + onSync: () -> Unit +) { + var currentPassword by rememberSaveable { mutableStateOf("") } + var newPassword by rememberSaveable { mutableStateOf("") } + var confirmPassword by rememberSaveable { mutableStateOf("") } + var showLogoutDialog by remember { mutableStateOf(false) } + + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(bottom = 20.dp), + verticalArrangement = Arrangement.spacedBy(0.dp) + ) { + item { + // Profile hero + Box( + modifier = Modifier + .fillMaxWidth() + .background(BrandGradient) + .padding(24.dp), + contentAlignment = Alignment.Center + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(10.dp)) { + Box( + modifier = Modifier.size(72.dp).background(Color.White.copy(alpha = 0.2f), CircleShape), + contentAlignment = Alignment.Center + ) { + Text( + (state.userName ?: state.userEmail ?: "U").take(1).uppercase(), + color = Color.White, fontSize = AppFontSizes.display, fontWeight = FontWeight.Bold + ) + } + Text(state.userName ?: "Usuario", color = Color.White, fontWeight = FontWeight.Bold, fontSize = AppFontSizes.title) + Text(state.userEmail ?: "", color = Color.White.copy(alpha = 0.7f), fontSize = AppFontSizes.base) + Surface(color = Color.White.copy(alpha = 0.15f), shape = RoundedCornerShape(20.dp)) { + Text( + "Rol: ${state.role ?: "user"} · ${state.lastSyncAt ?: "Sin sincronizar"}", + color = Color.White.copy(alpha = 0.9f), + fontSize = AppFontSizes.sm, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 5.dp) + ) + } + } + } + } + item { + Column(modifier = Modifier.padding(horizontal = 16.dp)) { + SectionCard(title = "Sincronización") { + OutlinedActionButton("Sincronizar ahora", Icons.Outlined.Sync, onClick = onSync) + } + } + } + item { + Column(modifier = Modifier.padding(horizontal = 16.dp)) { + SectionCard(title = "Cambio de contraseña") { + AppTextField(value = currentPassword, onValueChange = { currentPassword = it }, label = "Contraseña actual", isPassword = true) + AppTextField(value = newPassword, onValueChange = { newPassword = it }, label = "Nueva contraseña", isPassword = true) + PasswordChecklist(password = newPassword) + AppTextField(value = confirmPassword, onValueChange = { confirmPassword = it }, label = "Confirmar nueva contraseña", isPassword = true) + if (confirmPassword.isNotBlank() && newPassword != confirmPassword) { + Surface(color = Color(0xFFFEE2E2), shape = RoundedCornerShape(10.dp)) { + Text("Las contraseñas no coinciden.", color = Danger, modifier = Modifier.padding(10.dp), fontSize = AppFontSizes.md) + } + } + FilledActionButton("Solicitar cambio", Icons.Outlined.AccountCircle) { + if (passwordIssues(newPassword).isEmpty() && newPassword == confirmPassword) { + onRequestPasswordChange(currentPassword, newPassword) + currentPassword = "" + newPassword = "" + confirmPassword = "" + } + } + } + } + } + item { + Column(modifier = Modifier.padding(horizontal = 16.dp)) { + Surface( + shape = RoundedCornerShape(14.dp), + color = Color(0xFFFEF2F2), + border = BorderStroke(1.dp, Color(0xFFFECACA)) + ) { + Row( + modifier = Modifier.fillMaxWidth().clickable { showLogoutDialog = true }.padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Icon(Icons.Outlined.Logout, contentDescription = null, tint = Danger, modifier = Modifier.size(20.dp)) + Text("Cerrar sesión", color = Danger, fontWeight = FontWeight.SemiBold) + } + } + } + } + } + + if (showLogoutDialog) { + AlertDialog( + onDismissRequest = { showLogoutDialog = false }, + confirmButton = { + Button(onClick = { + showLogoutDialog = false + onLogout() + }) { Text("Salir") } + }, + dismissButton = { + OutlinedButton(onClick = { showLogoutDialog = false }) { Text("Cancelar") } + }, + title = { Text("Cerrar sesión") }, + text = { Text("¿Deseas salir de LinkedSign en este dispositivo?") } + ) + } +} + +@Composable +private fun StatCard( + label: String, + value: String, + icon: androidx.compose.ui.graphics.vector.ImageVector, + iconColor: Color, + modifier: Modifier = Modifier +) { + ElevatedCard( + modifier = modifier, + shape = RoundedCornerShape(16.dp), + elevation = CardDefaults.elevatedCardElevation(defaultElevation = 3.dp), + colors = CardDefaults.elevatedCardColors(containerColor = Color.White) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(14.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Box( + modifier = Modifier.size(38.dp).background(iconColor.copy(alpha = 0.12f), CircleShape), + contentAlignment = Alignment.Center + ) { + Icon(icon, contentDescription = null, tint = iconColor, modifier = Modifier.size(18.dp)) + } + Text(value, color = iconColor, fontSize = AppFontSizes.hero, fontWeight = FontWeight.Bold) + Text(label, color = TextMuted, fontSize = AppFontSizes.sm) + } + } +} + +@Composable +private fun SectionCard(title: String, content: @Composable ColumnScope.() -> Unit) { + ElevatedCard( + shape = RoundedCornerShape(16.dp), + elevation = CardDefaults.elevatedCardElevation(defaultElevation = 2.dp), + colors = CardDefaults.elevatedCardColors(containerColor = Color.White) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Box( + modifier = Modifier + .size(24.dp) + .background(BrandLight, RoundedCornerShape(8.dp)), + contentAlignment = Alignment.Center + ) { + Box(modifier = Modifier.width(8.dp).height(8.dp).background(Brand, CircleShape)) + } + Text(title, fontSize = AppFontSizes.lg, fontWeight = FontWeight.SemiBold, color = BrandDark) + } + content() + } + } +} + +@Composable +private fun DocumentRow(title: String, subtitle: String, onSelect: (() -> Unit)? = null, onView: () -> Unit, onDownload: () -> Unit, onDelete: () -> Unit) { + val isSigned = subtitle.contains("firmad", ignoreCase = true) || subtitle.contains("signed", ignoreCase = true) + val compactSubtitle = subtitle + .replace("signed", "", ignoreCase = true) + .replace("firmado", "", ignoreCase = true) + .replace(Regex("\\s+"), " ") + .trim() + Surface( + shape = RoundedCornerShape(14.dp), + color = Color.White, + border = BorderStroke(1.dp, StrokeSoft), + modifier = if (onSelect != null) Modifier.clickable(onClick = onSelect) else Modifier + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + Box( + modifier = Modifier + .width(34.dp) + .height(40.dp) + .background(if (isSigned) Color(0xFFE8F5E9) else BrandLight, RoundedCornerShape(6.dp)), + contentAlignment = Alignment.Center + ) { + Icon( + if (isSigned) Icons.Outlined.Verified else Icons.Outlined.PictureAsPdf, + contentDescription = if (isSigned) "Firmado" else "PDF", + tint = if (isSigned) Success else Brand, + modifier = Modifier.size(18.dp) + ) + } + Row( + modifier = Modifier.weight(1f), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + Text( + title, + fontWeight = FontWeight.Medium, + fontSize = AppFontSizes.base, + color = BrandDark, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + compactSubtitle.ifBlank { if (isSigned) "Documento listo" else "PDF original" }, + color = if (isSigned) Success else TextMuted, + fontSize = AppFontSizes.sm, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + Row(horizontalArrangement = Arrangement.spacedBy(2.dp), verticalAlignment = Alignment.CenterVertically) { + Icon( + if (isSigned) Icons.Outlined.Verified else Icons.Outlined.PictureAsPdf, + contentDescription = null, + tint = if (isSigned) Success else Warning, + modifier = Modifier.size(18.dp) + ) + IconButton(onClick = onView, modifier = Modifier.size(28.dp)) { + Icon(Icons.Outlined.Visibility, contentDescription = "Ver documento", tint = Brand, modifier = Modifier.size(17.dp)) + } + IconButton(onClick = onDownload, modifier = Modifier.size(28.dp)) { + Icon(Icons.Outlined.Download, contentDescription = "Descargar documento", tint = TextMuted, modifier = Modifier.size(17.dp)) + } + IconButton(onClick = onDelete, modifier = Modifier.size(28.dp)) { + Icon(Icons.Outlined.Delete, contentDescription = "Eliminar documento", tint = Danger, modifier = Modifier.size(17.dp)) + } + } + } + } + } + Spacer(Modifier.height(2.dp)) +} + +@Composable +private fun CertificateRow(certificate: CertificateItem) { + val isActive = (certificate.isActive ?: 0) == 1 + Surface( + shape = RoundedCornerShape(18.dp), + color = SurfaceAlt, + border = BorderStroke(1.dp, StrokeSoft) + ) { + Row(modifier = Modifier.fillMaxWidth().padding(14.dp), verticalAlignment = Alignment.Top) { + Box( + modifier = Modifier.size(44.dp).background(BrandLight, RoundedCornerShape(14.dp)), + contentAlignment = Alignment.Center + ) { + Icon(Icons.Outlined.Badge, contentDescription = null, tint = Brand, modifier = Modifier.size(22.dp)) + } + Spacer(Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Text(certificate.alias ?: "Certificado", fontWeight = FontWeight.SemiBold, fontSize = AppFontSizes.base, color = BrandDark) + Surface( + color = if (isActive) SuccessBg else WarningBg, + shape = RoundedCornerShape(6.dp) + ) { + Text( + if (isActive) "Activo" else "Inactivo", + color = if (isActive) Success else Warning, + fontSize = AppFontSizes.xs, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp) + ) + } + } + Text(certificate.subjectCn ?: "N/D", color = TextMuted, fontSize = AppFontSizes.sm, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text("Emisor: ${certificate.issuerCn ?: "N/D"}", color = Color(0xFF94A3B8), fontSize = AppFontSizes.xs, maxLines = 1, overflow = TextOverflow.Ellipsis) + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + AssistChip( + onClick = {}, + label = { Text("P12: ${certificate.certificateCount ?: 0}", fontSize = AppFontSizes.xs) }, + colors = AssistChipDefaults.assistChipColors(containerColor = BrandLight), + border = null + ) + AssistChip( + onClick = {}, + label = { Text(certificate.chainStatus ?: "Sin cadena", fontSize = AppFontSizes.xs) }, + colors = AssistChipDefaults.assistChipColors(containerColor = Color(0xFFF3F4F6)), + border = null + ) + } + } + } + } + Spacer(Modifier.height(8.dp)) +} + +@Composable +private fun CompactSectionLabel(label: String) { + Text( + label, + color = TextMuted, + fontSize = AppFontSizes.sm, + fontWeight = FontWeight.Medium, + modifier = Modifier.padding(bottom = 2.dp) + ) +} + +@Composable +private fun AltAuthTile(label: String) { + Surface( + modifier = Modifier.width(92.dp), + shape = RoundedCornerShape(12.dp), + color = Color.White, + border = BorderStroke(1.dp, StrokeSoft) + ) { + Column( + modifier = Modifier.padding(horizontal = 10.dp, vertical = 12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + Icon(Icons.Outlined.AccountCircle, contentDescription = null, tint = TextMuted, modifier = Modifier.size(18.dp)) + Text(label, color = TextMuted, fontSize = AppFontSizes.xs) + } + } +} + +@Composable +private fun FilledActionButton(label: String, icon: androidx.compose.ui.graphics.vector.ImageVector, modifier: Modifier = Modifier, enabled: Boolean = true, onClick: () -> Unit) { + Button( + onClick = onClick, + enabled = enabled, + modifier = modifier.fillMaxWidth().height(50.dp), + shape = RoundedCornerShape(24.dp), + colors = ButtonDefaults.buttonColors(containerColor = Brand), + contentPadding = PaddingValues(horizontal = 18.dp, vertical = 12.dp) + ) { + Box( + modifier = Modifier + .size(28.dp) + .background(Color.White.copy(alpha = 0.16f), CircleShape), + contentAlignment = Alignment.Center + ) { + Icon(icon, contentDescription = null, modifier = Modifier.size(16.dp), tint = Color.White) + } + Spacer(Modifier.width(10.dp)) + Text(label, fontWeight = FontWeight.SemiBold, fontSize = AppFontSizes.base) + } +} + +@Composable +private fun OutlinedActionButton(label: String, icon: androidx.compose.ui.graphics.vector.ImageVector, modifier: Modifier = Modifier, onClick: () -> Unit) { + OutlinedButton( + onClick = onClick, + modifier = modifier.fillMaxWidth().height(50.dp), + shape = RoundedCornerShape(24.dp), + border = BorderStroke(1.dp, StrokeSoft), + colors = ButtonDefaults.outlinedButtonColors( + containerColor = Color.White, + contentColor = Brand + ), + contentPadding = PaddingValues(horizontal = 18.dp, vertical = 12.dp) + ) { + Icon(icon, contentDescription = null, modifier = Modifier.size(16.dp), tint = Brand) + Spacer(Modifier.width(8.dp)) + Text(label, color = Brand, fontWeight = FontWeight.Medium, fontSize = AppFontSizes.base) + } +} + +@Composable +private fun AppTextField( + value: String, + onValueChange: (String) -> Unit, + label: String, + keyboardType: KeyboardType = KeyboardType.Text, + isPassword: Boolean = false +) { + var passwordVisible by rememberSaveable { mutableStateOf(false) } + + OutlinedTextField( + value = value, + onValueChange = onValueChange, + label = { Text(label, color = TextMuted) }, + modifier = Modifier.fillMaxWidth(), + keyboardOptions = KeyboardOptions(keyboardType = keyboardType), + visualTransformation = if (isPassword && !passwordVisible) { + PasswordVisualTransformation() + } else { + androidx.compose.ui.text.input.VisualTransformation.None + }, + shape = RoundedCornerShape(14.dp), + colors = OutlinedTextFieldDefaults.colors( + focusedTextColor = BrandDark, + unfocusedTextColor = BrandDark, + disabledTextColor = TextMuted, + focusedBorderColor = Brand, + unfocusedBorderColor = StrokeSoft, + focusedLabelColor = Brand, + unfocusedLabelColor = TextMuted, + cursorColor = Brand, + focusedContainerColor = Color.White, + unfocusedContainerColor = SurfaceAlt, + ), + trailingIcon = if (isPassword) { + { + IconButton(onClick = { passwordVisible = !passwordVisible }) { + Icon( + imageVector = if (passwordVisible) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility, + contentDescription = if (passwordVisible) "Ocultar contraseña" else "Mostrar contraseña", + tint = TextMuted + ) + } + } + } else { + null + } + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun DropdownSelector( + label: String, + options: Map, + selectedValue: String, + onSelect: (String) -> Unit +) { + var expanded by remember { mutableStateOf(false) } + val selectedLabel = options[selectedValue] ?: "Seleccionar" + + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text(label, fontWeight = FontWeight.SemiBold, color = BrandDark, fontSize = AppFontSizes.base) + Box( + modifier = Modifier + .fillMaxWidth() + .clickable { expanded = true } + ) { + OutlinedTextField( + value = selectedLabel, + onValueChange = {}, + modifier = Modifier.fillMaxWidth(), + enabled = true, + readOnly = true, + shape = RoundedCornerShape(14.dp), + colors = OutlinedTextFieldDefaults.colors( + focusedTextColor = BrandDark, + unfocusedTextColor = BrandDark, + disabledTextColor = Color(0xFF6B7280), + focusedBorderColor = Brand, + unfocusedBorderColor = StrokeSoft, + focusedContainerColor = Color.White, + unfocusedContainerColor = SurfaceAlt, + ) + ) + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + modifier = Modifier.background(Color.White) + ) { + options.forEach { (value, title) -> + DropdownMenuItem( + text = { Text(title, color = BrandDark, fontSize = AppFontSizes.lg) }, + onClick = { + onSelect(value) + expanded = false + } + ) + } + } + } + } +} + +@Composable +private fun PasswordChecklist(password: String) { + val issues = passwordIssues(password) + Surface(color = Color(0xFFF8FAFC), shape = RoundedCornerShape(12.dp)) { + Column(modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + listOf( + "Al menos 8 caracteres", + "Una mayúscula", + "Una minúscula", + "Un número", + "Un carácter especial" + ).forEach { rule -> + val valid = rule !in issues + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Box( + modifier = Modifier.size(16.dp).background( + if (valid) Success else Color(0xFFD1D5DB), CircleShape + ), + contentAlignment = Alignment.Center + ) { + Text(if (valid) "✓" else "", color = Color.White, fontSize = AppFontSizes.xxs, fontWeight = FontWeight.Bold) + } + Text( + text = rule, + color = if (valid) Color(0xFF16A34A) else Color(0xFF6B7280), + fontSize = AppFontSizes.md + ) + } + } + } + } +} + +private fun passwordIssues(password: String): List { + val missing = mutableListOf() + if (password.length < 8) missing += "Al menos 8 caracteres" + if (!password.any { it.isUpperCase() }) missing += "Una mayúscula" + if (!password.any { it.isLowerCase() }) missing += "Una minúscula" + if (!password.any { it.isDigit() }) missing += "Un número" + if (!password.any { !it.isLetterOrDigit() }) missing += "Un carácter especial" + return missing +} + +private fun queryFileName(context: Context, uri: Uri): String? { + return context.contentResolver.query(uri, null, null, null, null)?.use { cursor -> + val index = cursor.getColumnIndex("_display_name") + if (index >= 0 && cursor.moveToFirst()) cursor.getString(index) else null + } +} + +private fun downloadFile(context: Context, url: String, accessToken: String?, fileName: String) { + val request = DownloadManager.Request(Uri.parse(url)) + .setTitle(fileName) + .setDescription("Descargando archivo de LinkedSign") + .setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED) + .setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, fileName) + + if (!accessToken.isNullOrBlank()) { + request.addRequestHeader("Authorization", "Bearer $accessToken") + } + + val manager = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager + manager.enqueue(request) +} + +private suspend fun openSecurePdf(context: Context, url: String, accessToken: String?, fileName: String) { + val targetFile = downloadSecuredFileToCache(context, url, accessToken, fileName) + + val uri = FileProvider.getUriForFile(context, "net.linkedcorp.linkedsign.fileprovider", targetFile) + val intent = Intent(Intent.ACTION_VIEW).apply { + setDataAndType(uri, "application/pdf") + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(intent) +} + +private suspend fun downloadSecuredFileToCache( + context: Context, + url: String, + accessToken: String?, + fileName: String +): File = withContext(Dispatchers.IO) { + val requestBuilder = Request.Builder().url(url) + if (!accessToken.isNullOrBlank()) { + requestBuilder.addHeader("Authorization", "Bearer $accessToken") + } + + val response = OkHttpClient().newCall(requestBuilder.build()).execute() + if (!response.isSuccessful) { + throw AppException("No se pudo descargar el PDF.") + } + + val previewsDir = File(context.cacheDir, "pdf-preview").apply { mkdirs() } + val file = File(previewsDir, fileName) + response.body?.byteStream()?.use { input -> + file.outputStream().use { output -> input.copyTo(output) } + } + file +} + +private suspend fun renderPdfPreview(file: File, pageIndex: Int): PdfPreviewState = withContext(Dispatchers.IO) { + val descriptor = ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY) + val renderer = PdfRenderer(descriptor) + val safeIndex = pageIndex.coerceIn(0, renderer.pageCount - 1) + val page = renderer.openPage(safeIndex) + + val targetWidth = 1200 + val scale = targetWidth.toFloat() / page.width.toFloat() + val targetHeight = (page.height * scale).roundToInt().coerceAtLeast(1) + val bitmap = Bitmap.createBitmap(targetWidth, targetHeight, Bitmap.Config.ARGB_8888) + bitmap.eraseColor(android.graphics.Color.WHITE) + page.render(bitmap, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY) + + val result = PdfPreviewState( + file = file, + bitmap = bitmap.asImageBitmap(), + pageIndex = safeIndex, + pageCount = renderer.pageCount, + pageWidth = page.width, + pageHeight = page.height + ) + + page.close() + renderer.close() + descriptor.close() + result +} + +private fun getSignatureWidth(style: String, signerName: String): Int { + if (style != "qr") return 200 + val normalizedName = signerName.trim() + val calculatedWidth = 78 + (normalizedName.length * 4.9) + return calculatedWidth.roundToInt().coerceIn(170, 270) +} + +private fun canUseBiometricAccess(context: Context): Boolean { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) { + return false + } + + val result = BiometricManager.from(context) + .canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG or BiometricManager.Authenticators.BIOMETRIC_WEAK) + + return result == BiometricManager.BIOMETRIC_SUCCESS +} + +private fun requestBiometricUnlock( + activity: ComponentActivity, + onSuccess: () -> Unit, + onError: (String) -> Unit +) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) { + onError("La biometría no está disponible en esta versión de Android.") + return + } + + val executor = ContextCompat.getMainExecutor(activity) + val prompt = BiometricPrompt.Builder(activity) + .setTitle("Acceder con huella") + .setSubtitle("Confirma tu identidad para abrir LinkedSign") + .setNegativeButton("Cancelar", executor) { _, _ -> + onError("Autenticación biométrica cancelada.") + } + .build() + + val cancellationSignal = CancellationSignal() + prompt.authenticate(cancellationSignal, executor, object : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult?) { + onSuccess() + } + + override fun onAuthenticationError(errorCode: Int, errString: CharSequence?) { + if (errorCode != BiometricPrompt.BIOMETRIC_ERROR_USER_CANCELED && + errorCode != BiometricPrompt.BIOMETRIC_ERROR_CANCELED + ) { + onError(errString?.toString() ?: "No se pudo validar la huella.") + } + } + + override fun onAuthenticationFailed() { + onError("La huella no coincide. Intenta nuevamente.") + } + }) +} diff --git a/android/app/src/main/java/net/linkedcorp/linkedsign/ui/MainViewModel.kt b/android/app/src/main/java/net/linkedcorp/linkedsign/ui/MainViewModel.kt new file mode 100644 index 0000000..4ee8b54 --- /dev/null +++ b/android/app/src/main/java/net/linkedcorp/linkedsign/ui/MainViewModel.kt @@ -0,0 +1,194 @@ +package net.linkedcorp.linkedsign.ui + +import android.app.Application +import android.net.Uri +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.AndroidViewModel +import net.linkedcorp.linkedsign.data.AppException +import net.linkedcorp.linkedsign.data.CacheSnapshot +import net.linkedcorp.linkedsign.data.LinkedSignRepository +import net.linkedcorp.linkedsign.data.SessionSnapshot +import net.linkedcorp.linkedsign.data.SignRequest +import net.linkedcorp.linkedsign.data.SignatureRect +import net.linkedcorp.linkedsign.data.SignaturePreferences + +data class MainUiState( + val loading: Boolean = true, + val authenticated: Boolean = false, + val biometricEnabled: Boolean = false, + val biometricConfigured: Boolean = false, + val biometricEmail: String? = null, + val biometricHasCredentials: Boolean = false, + val emailForVerificationNotice: String? = null, + val userName: String? = null, + val userEmail: String? = null, + val role: String? = null, + val signaturePreferences: SignaturePreferences? = null, + val accessToken: String? = null, + val documents: List = emptyList(), + val certificates: List = emptyList(), + val signedDocuments: List = emptyList(), + val lastSyncAt: String? = null, + val syncing: Boolean = false +) + +class MainViewModel(application: Application) : AndroidViewModel(application) { + private val repository = LinkedSignRepository(application.applicationContext) + + var uiState by mutableStateOf(MainUiState()) + private set + + init { + val (session, cache) = repository.loadLocalSnapshot() + uiState = buildState(session, cache, loading = true) + } + + suspend fun bootstrap() { + val (session, cache) = repository.bootstrap() + uiState = buildState(session, cache, loading = false) + } + + 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) + } + + suspend fun biometricLogin() { + val session = repository.biometricLogin() + val (_, cache) = repository.loadLocalSnapshot() + uiState = buildState(session, cache, loading = false) + } + + suspend fun register(email: String, password: String) { + repository.register(email, password) + uiState = uiState.copy(emailForVerificationNotice = email) + } + + fun clearVerificationNotice() { + uiState = uiState.copy(emailForVerificationNotice = null) + } + + suspend fun syncNow() { + uiState = uiState.copy(syncing = true) + try { + val cache = repository.syncAll() + val session = repository.loadLocalSnapshot().first + uiState = buildState(session, cache, loading = false, syncing = false) + } catch (error: Exception) { + uiState = uiState.copy(syncing = false) + throw normalize(error) + } + } + + suspend fun uploadDocument(uri: Uri, fileName: String) { + repository.uploadDocument(uri, fileName) + syncNow() + } + + suspend fun uploadCertificate(uri: Uri, fileName: String, alias: String, password: String) { + repository.uploadCertificate(uri, fileName, alias, password) + syncNow() + } + + suspend fun deleteDocument(documentId: Int) { + repository.deleteDocument(documentId) + syncNow() + } + + suspend fun deleteSignedDocument(signatureId: Int) { + repository.deleteSignedDocument(signatureId) + syncNow() + } + + suspend fun signDocument( + documentId: Int, + certificateId: Int, + certPassword: String, + reason: String, + location: String, + page: Int, + rect: SignatureRect? + ) { + val preferences = repository.loadLocalSnapshot().first.user?.signaturePreferences + repository.signDocument( + SignRequest( + documentId = documentId, + certificateId = certificateId, + certPassword = certPassword, + reason = reason, + location = location, + page = page, + rect = rect, + signatureStyle = preferences?.signatureStyle ?: "qr", + qrPosition = preferences?.qrPosition ?: "right", + qrBackground = preferences?.qrBackground ?: "transparent", + qrBorder = preferences?.qrBorder ?: "none", + signatureImageBase64 = preferences?.signatureImageBase64 + ) + ) + syncNow() + } + + suspend fun requestPasswordChange(currentPassword: String, newPassword: String) { + repository.requestPasswordChange(currentPassword, newPassword) + } + + suspend fun logout() { + repository.logout() + val (session, cache) = repository.loadLocalSnapshot() + uiState = buildState(session, cache, loading = false) + } + + fun accessToken(): String? = repository.getAccessToken() + + fun enableBiometricAccess(email: String, password: String) { + repository.enableBiometricAccess(email, password) + refreshState() + } + + fun declineBiometricAccess() { + repository.declineBiometricAccess() + refreshState() + } + + private fun buildState( + session: SessionSnapshot, + cache: CacheSnapshot, + loading: Boolean, + syncing: Boolean = false + ): MainUiState { + val user = session.user ?: cache.user + val biometric = repository.biometricState() + return MainUiState( + loading = loading, + syncing = syncing, + authenticated = !session.accessToken.isNullOrBlank() && user != null, + biometricEnabled = biometric.enabled, + biometricConfigured = biometric.configured, + biometricEmail = biometric.email, + biometricHasCredentials = biometric.hasCredentials, + emailForVerificationNotice = uiState.emailForVerificationNotice, + userName = user?.name, + userEmail = user?.email, + role = user?.role, + signaturePreferences = user?.signaturePreferences, + accessToken = session.accessToken, + documents = cache.documents, + certificates = cache.certificates, + signedDocuments = cache.signedDocuments, + lastSyncAt = cache.lastSyncAt + ) + } + + private fun refreshState() { + val (session, cache) = repository.loadLocalSnapshot() + 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.") +} diff --git a/android/app/src/main/java/net/linkedcorp/linkedsign/ui/theme/Color.kt b/android/app/src/main/java/net/linkedcorp/linkedsign/ui/theme/Color.kt new file mode 100644 index 0000000..0b6487f --- /dev/null +++ b/android/app/src/main/java/net/linkedcorp/linkedsign/ui/theme/Color.kt @@ -0,0 +1,27 @@ +package net.linkedcorp.linkedsign.ui.theme + +import androidx.compose.ui.graphics.Color + +val LinkedSignPrimary = Color(0xFF4F46E5) +val LinkedSignOnPrimary = Color(0xFFFFFFFF) +val LinkedSignPrimaryContainer = Color(0xFFE0E7FF) +val LinkedSignOnPrimaryContainer = Color(0xFF1E1B4B) + +val LinkedSignSecondary = Color(0xFF111827) +val LinkedSignOnSecondary = Color(0xFFFFFFFF) +val LinkedSignSecondaryContainer = Color(0xFFE5E7EB) +val LinkedSignOnSecondaryContainer = Color(0xFF111827) + +val LinkedSignTertiary = Color(0xFF0F766E) +val LinkedSignOnTertiary = Color(0xFFFFFFFF) + +val LinkedSignBackground = Color(0xFFF3F4F6) +val LinkedSignOnBackground = Color(0xFF111827) +val LinkedSignSurface = Color(0xFFFFFFFF) +val LinkedSignOnSurface = Color(0xFF111827) +val LinkedSignSurfaceVariant = Color(0xFFF8FAFC) +val LinkedSignOnSurfaceVariant = Color(0xFF4B5563) + +val LinkedSignOutline = Color(0xFFD1D5DB) +val LinkedSignError = Color(0xFFB91C1C) +val LinkedSignOnError = Color(0xFFFFFFFF) diff --git a/android/app/src/main/java/net/linkedcorp/linkedsign/ui/theme/Theme.kt b/android/app/src/main/java/net/linkedcorp/linkedsign/ui/theme/Theme.kt new file mode 100644 index 0000000..7014eab --- /dev/null +++ b/android/app/src/main/java/net/linkedcorp/linkedsign/ui/theme/Theme.kt @@ -0,0 +1,107 @@ +package net.linkedcorp.linkedsign.ui.theme + +import android.app.Activity +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.core.view.WindowCompat + +private val LinkedSignLightColorScheme = lightColorScheme( + primary = LinkedSignPrimary, + onPrimary = LinkedSignOnPrimary, + primaryContainer = LinkedSignPrimaryContainer, + onPrimaryContainer = LinkedSignOnPrimaryContainer, + secondary = LinkedSignSecondary, + onSecondary = LinkedSignOnSecondary, + secondaryContainer = LinkedSignSecondaryContainer, + onSecondaryContainer = LinkedSignOnSecondaryContainer, + tertiary = LinkedSignTertiary, + onTertiary = LinkedSignOnTertiary, + background = LinkedSignBackground, + onBackground = LinkedSignOnBackground, + surface = LinkedSignSurface, + onSurface = LinkedSignOnSurface, + surfaceVariant = LinkedSignSurfaceVariant, + onSurfaceVariant = LinkedSignOnSurfaceVariant, + outline = LinkedSignOutline, + error = LinkedSignError, + onError = LinkedSignOnError +) + +private val LinkedSignDarkColorScheme = darkColorScheme( + primary = LinkedSignPrimaryContainer, + onPrimary = LinkedSignOnPrimaryContainer, + primaryContainer = LinkedSignPrimary, + onPrimaryContainer = LinkedSignOnPrimary, + secondary = ColorTokens.DarkSecondary, + onSecondary = LinkedSignOnPrimary, + secondaryContainer = ColorTokens.DarkSecondaryContainer, + onSecondaryContainer = LinkedSignOnPrimary, + tertiary = ColorTokens.DarkTertiary, + onTertiary = LinkedSignOnPrimary, + background = ColorTokens.DarkBackground, + onBackground = ColorTokens.DarkOnBackground, + surface = ColorTokens.DarkSurface, + onSurface = ColorTokens.DarkOnSurface, + surfaceVariant = ColorTokens.DarkSurfaceVariant, + onSurfaceVariant = ColorTokens.DarkOnSurfaceVariant, + outline = ColorTokens.DarkOutline, + error = LinkedSignError, + onError = LinkedSignOnError +) + +private object ColorTokens { + val DarkSecondary = androidx.compose.ui.graphics.Color(0xFF1F2937) + val DarkSecondaryContainer = androidx.compose.ui.graphics.Color(0xFF374151) + val DarkTertiary = androidx.compose.ui.graphics.Color(0xFF14B8A6) + val DarkBackground = androidx.compose.ui.graphics.Color(0xFF030712) + val DarkOnBackground = androidx.compose.ui.graphics.Color(0xFFF9FAFB) + val DarkSurface = androidx.compose.ui.graphics.Color(0xFF111827) + val DarkOnSurface = androidx.compose.ui.graphics.Color(0xFFF9FAFB) + val DarkSurfaceVariant = androidx.compose.ui.graphics.Color(0xFF1F2937) + val DarkOnSurfaceVariant = androidx.compose.ui.graphics.Color(0xFFD1D5DB) + val DarkOutline = androidx.compose.ui.graphics.Color(0xFF4B5563) +} + +@Composable +fun LinkedSignTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + dynamicColor: Boolean = false, + content: @Composable () -> Unit +) { + val colorScheme = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + + darkTheme -> LinkedSignDarkColorScheme + else -> LinkedSignLightColorScheme + } + + val view = LocalView.current + if (!view.isInEditMode) { + SideEffect { + val window = (view.context as Activity).window + window.statusBarColor = colorScheme.background.toArgb() + window.navigationBarColor = colorScheme.surface.toArgb() + WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme + WindowCompat.getInsetsController(window, view).isAppearanceLightNavigationBars = !darkTheme + } + } + + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content + ) +} diff --git a/android/app/src/main/java/net/linkedcorp/linkedsign/ui/theme/Type.kt b/android/app/src/main/java/net/linkedcorp/linkedsign/ui/theme/Type.kt new file mode 100644 index 0000000..5e9e97a --- /dev/null +++ b/android/app/src/main/java/net/linkedcorp/linkedsign/ui/theme/Type.kt @@ -0,0 +1,85 @@ +package net.linkedcorp.linkedsign.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.sp + +object AppFontSizes { + val xxs: TextUnit = 9.sp + val xs: TextUnit = 10.sp + val sm: TextUnit = 11.sp + val md: TextUnit = 12.sp + val base: TextUnit = 13.sp + val lg: TextUnit = 14.sp + val xl: TextUnit = 15.sp + val title: TextUnit = 18.sp + val hero: TextUnit = 22.sp + val brand: TextUnit = 24.sp + val display: TextUnit = 28.sp +} + +val Typography = Typography( + headlineMedium = TextStyle( + fontFamily = FontFamily.SansSerif, + fontWeight = FontWeight.Bold, + fontSize = AppFontSizes.display, + lineHeight = 34.sp + ), + headlineSmall = TextStyle( + fontFamily = FontFamily.SansSerif, + fontWeight = FontWeight.Bold, + fontSize = 24.sp, + lineHeight = 30.sp + ), + titleLarge = TextStyle( + fontFamily = FontFamily.SansSerif, + fontWeight = FontWeight.SemiBold, + fontSize = 20.sp, + lineHeight = 26.sp + ), + titleMedium = TextStyle( + fontFamily = FontFamily.SansSerif, + fontWeight = FontWeight.SemiBold, + fontSize = 16.sp, + lineHeight = 22.sp + ), + bodyLarge = TextStyle( + fontFamily = FontFamily.SansSerif, + fontWeight = FontWeight.Normal, + fontSize = AppFontSizes.lg, + lineHeight = 24.sp + ), + bodyMedium = TextStyle( + fontFamily = FontFamily.SansSerif, + fontWeight = FontWeight.Normal, + fontSize = AppFontSizes.base, + lineHeight = 20.sp + ), + bodySmall = TextStyle( + fontFamily = FontFamily.SansSerif, + fontWeight = FontWeight.Normal, + fontSize = AppFontSizes.md, + lineHeight = 18.sp + ), + labelLarge = TextStyle( + fontFamily = FontFamily.SansSerif, + fontWeight = FontWeight.Medium, + fontSize = AppFontSizes.base, + lineHeight = 20.sp + ), + labelMedium = TextStyle( + fontFamily = FontFamily.SansSerif, + fontWeight = FontWeight.Medium, + fontSize = AppFontSizes.md, + lineHeight = 16.sp + ), + labelSmall = TextStyle( + fontFamily = FontFamily.SansSerif, + fontWeight = FontWeight.Medium, + fontSize = AppFontSizes.sm, + lineHeight = 14.sp + ) +) diff --git a/android/app/src/main/res/drawable/ic_launcher_foreground.xml b/android/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..ae4284b --- /dev/null +++ b/android/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..a8a8fa5 --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..a8a8fa5 --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..4d43263 --- /dev/null +++ b/android/app/src/main/res/values/colors.xml @@ -0,0 +1,5 @@ + + #4F46E5 + #111827 + #F3F4F6 + diff --git a/android/app/src/main/res/values/ic_launcher_background.xml b/android/app/src/main/res/values/ic_launcher_background.xml new file mode 100644 index 0000000..3aad639 --- /dev/null +++ b/android/app/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #111827 + diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..09628c1 --- /dev/null +++ b/android/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + LinkedSign + diff --git a/android/app/src/main/res/values/themes.xml b/android/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..b053617 --- /dev/null +++ b/android/app/src/main/res/values/themes.xml @@ -0,0 +1,8 @@ + + + diff --git a/android/app/src/main/res/xml/file_paths.xml b/android/app/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..416ca35 --- /dev/null +++ b/android/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,9 @@ + + + + + diff --git a/android/build.gradle b/android/build.gradle new file mode 100644 index 0000000..211cf59 --- /dev/null +++ b/android/build.gradle @@ -0,0 +1,25 @@ +buildscript { + ext { + kotlin_version = '1.9.24' + compose_compiler_version = '1.5.14' + } + repositories { + google() + mavenCentral() + } + dependencies { + classpath 'com.android.tools.build:gradle:8.5.2' + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + } +} + +allprojects { + repositories { + google() + mavenCentral() + } +} + +tasks.register('clean', Delete) { + delete rootProject.buildDir +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..8f2e28c --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,4 @@ +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +android.useAndroidX=true +android.nonTransitiveRClass=true +kotlin.code.style=official diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..a4b76b9530d66f5e68d973ea569d8e19de379189 GIT binary patch literal 43583 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 yu?e%!_ptzGB|8GrCfu%p?(_ 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 GZ~q4|mN%#X literal 0 HcmV?d00001 diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..31f6cef --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=file:///home/mgonzalez/desarrollo/react/linkedsign/android/.gradle-dist/gradle-8.7-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/android/gradlew b/android/gradlew new file mode 100755 index 0000000..f5feea6 --- /dev/null +++ b/android/gradlew @@ -0,0 +1,252 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s +' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/android/settings.gradle b/android/settings.gradle new file mode 100644 index 0000000..932e25f --- /dev/null +++ b/android/settings.gradle @@ -0,0 +1,2 @@ +rootProject.name = "LinkedSign" +include ':app' diff --git a/android_firma_electronica.html b/android_firma_electronica.html new file mode 100644 index 0000000..da2000a --- /dev/null +++ b/android_firma_electronica.html @@ -0,0 +1,457 @@ + + + + + +FirmaDoc — Prototipo Android + + + + + + +

FirmaDoc

+

Prototipo de sistema Android para firma electrónica de documentos

+ +
+ + +
+
+ 9:41 +
+ + +
+
+
+ +
+
FirmaDoc
+
Firma electrónica
+
+
+ +
+
+
+ +
+
+ PDF +
+
+
Contrato_servicios.pdf
+
2.4 MB · Pendiente
+
+
+
+
+
+ PDF +
+
+
Acuerdo_confidencial.pdf
+
1.1 MB · Firmado
+
+ +
+
+
+ PDF +
+
+
Propuesta_2024.pdf
+
890 KB · Pendiente
+
+
+
+
+
+ + Subir documento +
+ +
+ + +
+
+ 9:41 +
+ +
+
+
+ +
+
Contrato_servicios.pdf
+
Vista previa
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + Zona de firma +
+
+
+
+
+ + Firmar este documento +
+
+
+ + +
+
+ 9:41 +
+ +
+
+
+ +
Dibujar firma
+
+
+
Dibujar
+
Escribir
+
Imagen
+
+
+
+ Firme aquí con su dedo + + + +
+
+
+
+
+
+
+
+
+ Grosor +
+
+
+
+
Limpiar
+
+
+
+ + Confirmar firma +
+
+ + +
+
+ 9:41 +
+ +
+
+
+
Verificar identidad
+
+
+
+
+ + + + + + + +
+
Huella dactilar
+
Coloque su dedo en el sensor
+
+
o use
+
+
+ + Face ID +
+
+ + PIN +
+
+ + SMS OTP +
+
+
+
+ + +
+
+ 9:41 +
+ +
+
+
+
Documento firmado
+
+
+
+
+ +
+
¡Firmado con éxito!
+
Contrato_servicios.pdf
+
+
+
Certificado digital
+
FirmanteCarlos Mendieta
+
Fecha y hora06/04/2026 09:41
+
Hash SHA-256a4f2b9e1...
+
Válido hasta2027-04-06
+
+
+
+ + Guardar +
+
+ + Compartir +
+
+ + Auditoría +
+
+
+
+ +
+ + +
+ 1. Inicio + + 2. Vista previa + + 3. Dibujar firma + + 4. Biometría + + 5. Completado +
+ + + diff --git a/android_react/.gitignore b/android_react/.gitignore new file mode 100644 index 0000000..9ccac9a --- /dev/null +++ b/android_react/.gitignore @@ -0,0 +1,6 @@ +node_modules +.expo +dist +android +ios +*.log diff --git a/android_react/App.js b/android_react/App.js new file mode 100644 index 0000000..a4803ae --- /dev/null +++ b/android_react/App.js @@ -0,0 +1,18 @@ +import { StatusBar } from 'expo-status-bar'; +import { SafeAreaProvider } from 'react-native-safe-area-context'; +import { AuthProvider } from './src/context/AuthContext'; +import { SyncProvider } from './src/context/SyncContext'; +import AppNavigator from './src/navigation/AppNavigator'; + +export default function App() { + return ( + + + + + + + + + ); +} diff --git a/android_react/README.md b/android_react/README.md new file mode 100644 index 0000000..bc7c18c --- /dev/null +++ b/android_react/README.md @@ -0,0 +1,101 @@ +# LinkedSign Android + +Aplicación móvil Android de LinkedSign construida con Expo + React Native. Esta app complementa a la versión web y usa el mismo backend. + +## Objetivo + +- compartir autenticación con la app web +- guardar la sesión localmente en el teléfono +- mantener cache local para trabajo móvil +- sincronizar documentos, certificados y firmados solo cuando el usuario lo solicite + +## Alcance actual + +- login +- registro +- aviso de verificación por correo +- dashboard con estado de sincronización +- documentos originales y firmados +- subida de PDF original +- descarga y compartir documentos firmados +- certificados +- subida de `.p12` / `.pfx` +- firmado básico usando la configuración visual ya guardada por el usuario +- perfil y solicitud de cambio de contraseña +- base local SQLite para cache + +## Cómo se complementa con la web + +- la web sigue siendo el cliente principal para flujos avanzados +- Android usa el mismo usuario, misma contraseña y mismo backend +- la sesión se guarda en el teléfono con `expo-secure-store` +- los datos de negocio no se sincronizan solos: el usuario decide cuándo sincronizar +- el estilo visual de firma se toma del backend, por lo que si lo cambias en la web también se usa al firmar desde Android + +## Estructura + +```text +android/ + App.js + app.json + package.json + src/ + api/ + components/ + config/ + context/ + navigation/ + screens/ + storage/ + theme/ + utils/ +``` + +## Configuración + +La URL del backend se define en [app.json](/home/mgonzalez/desarrollo/react/linkedsign/android/app.json) y puede sobreescribirse con `EXPO_PUBLIC_API_BASE_URL`. + +Valor actual: + +```json +{ + "expo": { + "extra": { + "apiBaseUrl": "http://192.168.222.72:4000" + } + } +} +``` + +## Ejecutar + +```bash +cd /home/mgonzalez/desarrollo/react/linkedsign/android +npm install +npm run start +``` + +Para compilar o abrir en Android: + +```bash +npm run android +``` + +## Sincronización + +La app guarda cache local en SQLite para: + +- perfil +- documentos originales +- certificados +- documentos firmados +- fecha de última sincronización + +La sincronización se ejecuta manualmente desde la app. Eso evita sobreescribir datos locales o consumir red sin que el usuario lo pida. + +## Notas + +- La confirmación de correo y el cambio de contraseña se validan contra el mismo backend de LinkedSign. +- La validación del enlace de correo puede abrirse en navegador si el enlace apunta al frontend web. +- Para una prueba real en dispositivo, el teléfono debe poder alcanzar la IP del backend. +- Este cliente no reemplaza la web; la complementa con acceso móvil, cache local y sincronización bajo demanda. diff --git a/android_react/app.json b/android_react/app.json new file mode 100644 index 0000000..4eca3d9 --- /dev/null +++ b/android_react/app.json @@ -0,0 +1,22 @@ +{ + "expo": { + "name": "LinkedSign", + "slug": "linkedsign-android", + "scheme": "linkedsign", + "version": "0.1.0", + "orientation": "portrait", + "userInterfaceStyle": "light", + "splash": { + "backgroundColor": "#111827" + }, + "assetBundlePatterns": [ + "**/*" + ], + "android": { + "package": "net.linkedcorp.linkedsign" + }, + "extra": { + "apiBaseUrl": "http://192.168.222.72:4000" + } + } +} diff --git a/android_react/babel.config.js b/android_react/babel.config.js new file mode 100644 index 0000000..9d89e13 --- /dev/null +++ b/android_react/babel.config.js @@ -0,0 +1,6 @@ +module.exports = function (api) { + api.cache(true); + return { + presets: ['babel-preset-expo'], + }; +}; diff --git a/android_react/package.json b/android_react/package.json new file mode 100644 index 0000000..387b2de --- /dev/null +++ b/android_react/package.json @@ -0,0 +1,29 @@ +{ + "name": "linkedsign-android", + "version": "0.1.0", + "private": true, + "main": "expo/AppEntry", + "scripts": { + "start": "expo start", + "android": "expo run:android", + "web": "expo start --web" + }, + "dependencies": { + "@react-navigation/bottom-tabs": "^7.3.10", + "@react-navigation/native": "^7.1.8", + "@react-navigation/native-stack": "^7.3.12", + "axios": "^1.9.0", + "expo": "~53.0.0", + "expo-document-picker": "~14.0.0", + "expo-file-system": "~18.0.0", + "expo-linking": "~7.0.0", + "expo-secure-store": "~14.0.0", + "expo-sharing": "~13.0.0", + "expo-sqlite": "~15.0.0", + "expo-status-bar": "~2.2.3", + "react": "19.0.0", + "react-native": "0.79.0", + "react-native-safe-area-context": "^5.4.0", + "react-native-screens": "^4.11.1" + } +} diff --git a/android_react/src/api/auth.js b/android_react/src/api/auth.js new file mode 100644 index 0000000..bc42e41 --- /dev/null +++ b/android_react/src/api/auth.js @@ -0,0 +1,7 @@ +import client from './client'; + +export const login = (payload) => client.post('/auth/login', payload); +export const register = (payload) => client.post('/auth/register', payload); +export const getMe = () => client.get('/auth/me'); +export const logout = (refreshToken) => client.post('/auth/logout', { refreshToken }); +export const changePassword = (payload) => client.patch('/auth/change-password', payload); diff --git a/android_react/src/api/certificates.js b/android_react/src/api/certificates.js new file mode 100644 index 0000000..e84d488 --- /dev/null +++ b/android_react/src/api/certificates.js @@ -0,0 +1,7 @@ +import client from './client'; + +export const listCertificates = () => client.get('/certificates'); +export const uploadCertificate = (formData) => + client.post('/certificates', formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + }); diff --git a/android_react/src/api/client.js b/android_react/src/api/client.js new file mode 100644 index 0000000..17db0e6 --- /dev/null +++ b/android_react/src/api/client.js @@ -0,0 +1,78 @@ +import axios from 'axios'; +import { API_BASE_URL } from '../config/env'; +import { loadSession, saveSession, clearSession } from '../storage/session'; + +const client = axios.create({ + baseURL: API_BASE_URL, + timeout: 15000, +}); + +let isRefreshing = false; +let pendingQueue = []; + +const resolveQueue = (error, token = null) => { + pendingQueue.forEach(({ resolve, reject }) => { + if (error) reject(error); + else resolve(token); + }); + pendingQueue = []; +}; + +client.interceptors.request.use(async (config) => { + const { accessToken } = await loadSession(); + if (accessToken) { + config.headers.Authorization = `Bearer ${accessToken}`; + } + return config; +}); + +client.interceptors.response.use( + (response) => response, + async (error) => { + const originalRequest = error.config; + + if (error.response?.status !== 401 || originalRequest?._retry) { + throw error; + } + + const session = await loadSession(); + if (!session.refreshToken) { + await clearSession(); + throw error; + } + + if (isRefreshing) { + const token = await new Promise((resolve, reject) => { + pendingQueue.push({ resolve, reject }); + }); + originalRequest.headers.Authorization = `Bearer ${token}`; + return client(originalRequest); + } + + isRefreshing = true; + originalRequest._retry = true; + + try { + const response = await axios.post(`${API_BASE_URL}/auth/refresh`, { + refreshToken: session.refreshToken, + }); + const { accessToken, refreshToken } = response.data.data; + await saveSession({ + accessToken, + refreshToken, + user: session.user, + }); + resolveQueue(null, accessToken); + originalRequest.headers.Authorization = `Bearer ${accessToken}`; + return client(originalRequest); + } catch (refreshError) { + resolveQueue(refreshError, null); + await clearSession(); + throw refreshError; + } finally { + isRefreshing = false; + } + } +); + +export default client; diff --git a/android_react/src/api/documents.js b/android_react/src/api/documents.js new file mode 100644 index 0000000..8500eb8 --- /dev/null +++ b/android_react/src/api/documents.js @@ -0,0 +1,7 @@ +import client from './client'; + +export const listDocuments = () => client.get('/documents?limit=100'); +export const uploadDocument = (formData) => + client.post('/documents', formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + }); diff --git a/android_react/src/api/signatures.js b/android_react/src/api/signatures.js new file mode 100644 index 0000000..58c7376 --- /dev/null +++ b/android_react/src/api/signatures.js @@ -0,0 +1,4 @@ +import client from './client'; + +export const listSignedDocuments = () => client.get('/signatures/mine'); +export const signDocument = (payload) => client.post('/signatures/sign', payload); diff --git a/android_react/src/components/PasswordRules.js b/android_react/src/components/PasswordRules.js new file mode 100644 index 0000000..54d0c6a --- /dev/null +++ b/android_react/src/components/PasswordRules.js @@ -0,0 +1,47 @@ +import { StyleSheet, Text, View } from 'react-native'; +import { getPasswordChecks } from '../utils/passwordRules'; +import { colors } from '../theme/colors'; + +export default function PasswordRules({ password }) { + const checks = getPasswordChecks(password); + + return ( + + {checks.map((check) => ( + + + {check.valid ? '✓' : '•'} + + + {check.label} + + + ))} + + ); +} + +const styles = StyleSheet.create({ + wrapper: { + gap: 4, + marginTop: 8, + }, + row: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + }, + icon: { + fontSize: 12, + fontWeight: '700', + }, + label: { + fontSize: 12, + }, + ok: { + color: colors.success, + }, + pending: { + color: colors.textMuted, + }, +}); diff --git a/android_react/src/components/PrimaryButton.js b/android_react/src/components/PrimaryButton.js new file mode 100644 index 0000000..b509fe5 --- /dev/null +++ b/android_react/src/components/PrimaryButton.js @@ -0,0 +1,47 @@ +import { ActivityIndicator, Pressable, StyleSheet, Text } from 'react-native'; +import { colors } from '../theme/colors'; + +export default function PrimaryButton({ title, onPress, variant = 'primary', disabled = false }) { + return ( + + {disabled ? : ( + {title} + )} + + ); +} + +const styles = StyleSheet.create({ + button: { + minHeight: 48, + borderRadius: 12, + backgroundColor: colors.primary, + alignItems: 'center', + justifyContent: 'center', + paddingHorizontal: 16, + }, + secondary: { + backgroundColor: colors.surface, + borderWidth: 1, + borderColor: colors.border, + }, + disabled: { + opacity: 0.65, + }, + label: { + color: '#fff', + fontWeight: '700', + fontSize: 15, + }, + secondaryLabel: { + color: colors.text, + }, +}); diff --git a/android_react/src/components/ScreenContainer.js b/android_react/src/components/ScreenContainer.js new file mode 100644 index 0000000..dc578ba --- /dev/null +++ b/android_react/src/components/ScreenContainer.js @@ -0,0 +1,26 @@ +import { ScrollView, StyleSheet, View } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { colors } from '../theme/colors'; + +export default function ScreenContainer({ children, scroll = true, contentContainerStyle }) { + const content = scroll ? ( + + {children} + + ) : ( + {children} + ); + + return {content}; +} + +const styles = StyleSheet.create({ + safe: { + flex: 1, + backgroundColor: colors.background, + }, + scrollContent: { + padding: 16, + gap: 16, + }, +}); diff --git a/android_react/src/config/env.js b/android_react/src/config/env.js new file mode 100644 index 0000000..dcbcff5 --- /dev/null +++ b/android_react/src/config/env.js @@ -0,0 +1,8 @@ +import Constants from 'expo-constants'; + +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'; diff --git a/android_react/src/context/AuthContext.js b/android_react/src/context/AuthContext.js new file mode 100644 index 0000000..ae52e60 --- /dev/null +++ b/android_react/src/context/AuthContext.js @@ -0,0 +1,86 @@ +import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'; +import { Alert } 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'; + +const AuthContext = createContext(null); + +export const AuthProvider = ({ children }) => { + const [user, setUser] = useState(null); + const [tokens, setTokens] = useState({ accessToken: null, refreshToken: null }); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const bootstrap = async () => { + initLocalDb(); + const session = await loadSession(); + if (!session.accessToken || !session.refreshToken) { + setLoading(false); + return; + } + + setTokens({ accessToken: session.accessToken, refreshToken: session.refreshToken }); + + try { + const { data } = await getMe(); + setUser(data.data.user); + saveProfileCache(data.data.user); + } catch (error) { + await clearSession(); + } finally { + setLoading(false); + } + }; + + bootstrap(); + }, []); + + const login = useCallback(async (email, password) => { + const { data } = await apiLogin({ email, password }); + const payload = data.data; + await saveSession(payload); + saveProfileCache(payload.user); + setUser(payload.user); + setTokens({ accessToken: payload.accessToken, refreshToken: payload.refreshToken }); + return payload.user; + }, []); + + const register = useCallback(async ({ email, password }) => { + return apiRegister({ email, password }); + }, []); + + const logout = useCallback(async () => { + try { + const session = await loadSession(); + if (session.refreshToken) { + await apiLogout(session.refreshToken); + } + } 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 }); + } + }, []); + + const requestPasswordChange = useCallback(async ({ currentPassword, newPassword }) => { + return apiChangePassword({ currentPassword, newPassword }); + }, []); + + const value = useMemo(() => ({ + user, + tokens, + loading, + isAuthenticated: Boolean(user && tokens.accessToken), + login, + register, + logout, + requestPasswordChange, + }), [user, tokens, loading, login, register, logout, requestPasswordChange]); + + return {children}; +}; + +export const useAuth = () => useContext(AuthContext); diff --git a/android_react/src/context/SyncContext.js b/android_react/src/context/SyncContext.js new file mode 100644 index 0000000..af66128 --- /dev/null +++ b/android_react/src/context/SyncContext.js @@ -0,0 +1,53 @@ +import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'; +import { getCertificatesCache, getDocumentsCache, getSignedDocumentsCache, getSyncMeta, initLocalDb } from '../storage/localDb'; +import { syncAllData } from '../storage/syncService'; +import { useAuth } from './AuthContext'; + +const SyncContext = createContext(null); + +export const SyncProvider = ({ children }) => { + const { user, isAuthenticated } = useAuth(); + const [syncing, setSyncing] = useState(false); + const [documents, setDocuments] = useState([]); + const [certificates, setCertificates] = useState([]); + const [signedDocuments, setSignedDocuments] = useState([]); + const [lastSyncAt, setLastSyncAt] = useState(null); + + const loadCache = useCallback(() => { + initLocalDb(); + setDocuments(getDocumentsCache()); + setCertificates(getCertificatesCache()); + setSignedDocuments(getSignedDocumentsCache()); + setLastSyncAt(getSyncMeta('last_sync_at')?.value || null); + }, []); + + useEffect(() => { + loadCache(); + }, [loadCache, isAuthenticated]); + + const syncNow = useCallback(async () => { + if (!isAuthenticated || !user) return null; + setSyncing(true); + try { + const result = await syncAllData({ user }); + loadCache(); + return result; + } finally { + setSyncing(false); + } + }, [isAuthenticated, user, loadCache]); + + const value = useMemo(() => ({ + syncing, + documents, + certificates, + signedDocuments, + lastSyncAt, + syncNow, + refreshLocalCache: loadCache, + }), [syncing, documents, certificates, signedDocuments, lastSyncAt, syncNow, loadCache]); + + return {children}; +}; + +export const useSync = () => useContext(SyncContext); diff --git a/android_react/src/navigation/AppNavigator.js b/android_react/src/navigation/AppNavigator.js new file mode 100644 index 0000000..85f26cb --- /dev/null +++ b/android_react/src/navigation/AppNavigator.js @@ -0,0 +1,74 @@ +import { ActivityIndicator, Text, View } from 'react-native'; +import { NavigationContainer, DefaultTheme } from '@react-navigation/native'; +import { createBottomTabNavigator } from '@react-navigation/bottom-tabs'; +import { createNativeStackNavigator } from '@react-navigation/native-stack'; +import { useAuth } from '../context/AuthContext'; +import { colors } from '../theme/colors'; +import LoginScreen from '../screens/auth/LoginScreen'; +import RegisterScreen from '../screens/auth/RegisterScreen'; +import VerifyNoticeScreen from '../screens/auth/VerifyNoticeScreen'; +import DashboardScreen from '../screens/app/DashboardScreen'; +import DocumentsScreen from '../screens/app/DocumentsScreen'; +import CertificatesScreen from '../screens/app/CertificatesScreen'; +import SignScreen from '../screens/app/SignScreen'; +import ProfileScreen from '../screens/app/ProfileScreen'; + +const AuthStack = createNativeStackNavigator(); +const Tab = createBottomTabNavigator(); + +const navTheme = { + ...DefaultTheme, + colors: { + ...DefaultTheme.colors, + background: colors.background, + card: colors.surface, + text: colors.text, + primary: colors.primary, + border: colors.border, + }, +}; + +function AppTabs() { + return ( + + + + + + + + ); +} + +export default function AppNavigator() { + const { loading, isAuthenticated } = useAuth(); + + if (loading) { + return ( + + + Cargando LinkedSign... + + ); + } + + return ( + + {isAuthenticated ? ( + + ) : ( + + + + + + )} + + ); +} diff --git a/android_react/src/screens/app/CertificatesScreen.js b/android_react/src/screens/app/CertificatesScreen.js new file mode 100644 index 0000000..bef7c08 --- /dev/null +++ b/android_react/src/screens/app/CertificatesScreen.js @@ -0,0 +1,125 @@ +import { Alert, FlatList, StyleSheet, Text, TextInput, View } from 'react-native'; +import { useState } from 'react'; +import * as DocumentPicker from 'expo-document-picker'; +import ScreenContainer from '../../components/ScreenContainer'; +import PrimaryButton from '../../components/PrimaryButton'; +import { colors } from '../../theme/colors'; +import { useSync } from '../../context/SyncContext'; +import { uploadCertificate } from '../../api/certificates'; + +export default function CertificatesScreen() { + const { certificates, syncNow } = useSync(); + const [alias, setAlias] = useState(''); + const [password, setPassword] = useState(''); + + const handleSync = async () => { + try { + await syncNow(); + Alert.alert('Certificados', 'Certificados sincronizados.'); + } catch (error) { + Alert.alert('Certificados', error.response?.data?.error || 'No se pudo sincronizar.'); + } + }; + + const handleUpload = async () => { + const picked = await DocumentPicker.getDocumentAsync({ + type: [ + 'application/x-pkcs12', + 'application/octet-stream', + ], + copyToCacheDirectory: true, + }); + + if (picked.canceled) return; + const file = picked.assets[0]; + const formData = new FormData(); + formData.append('file', { + uri: file.uri, + name: file.name, + type: file.mimeType || 'application/octet-stream', + }); + formData.append('alias', alias); + formData.append('password', password); + + try { + await uploadCertificate(formData); + setAlias(''); + setPassword(''); + await syncNow(); + Alert.alert('Certificados', 'Certificado subido correctamente.'); + } catch (error) { + Alert.alert('Certificados', error.response?.data?.error || 'No se pudo subir el certificado.'); + } + }; + + return ( + + + Subir certificado + + + + + + + + + Certificados sincronizados + `${item.id}`} + scrollEnabled={false} + ItemSeparatorComponent={() => } + renderItem={({ item }) => ( + + {item.alias} + {item.subject_cn || 'Sin sujeto'} + Válido hasta: {item.valid_to || 'N/D'} + + )} + ListEmptyComponent={No hay certificados sincronizados.} + /> + + + ); +} + +const styles = StyleSheet.create({ + card: { + backgroundColor: colors.surface, + borderRadius: 18, + padding: 16, + gap: 12, + }, + title: { + fontSize: 18, + fontWeight: '700', + color: colors.text, + }, + input: { + borderWidth: 1, + borderColor: colors.border, + borderRadius: 12, + paddingHorizontal: 14, + paddingVertical: 12, + backgroundColor: '#fff', + }, + item: { + borderWidth: 1, + borderColor: colors.border, + borderRadius: 14, + padding: 14, + gap: 4, + }, + itemTitle: { + fontWeight: '700', + color: colors.text, + }, + itemMeta: { + color: colors.textMuted, + fontSize: 12, + }, + empty: { + color: colors.textMuted, + }, +}); diff --git a/android_react/src/screens/app/DashboardScreen.js b/android_react/src/screens/app/DashboardScreen.js new file mode 100644 index 0000000..2b611b2 --- /dev/null +++ b/android_react/src/screens/app/DashboardScreen.js @@ -0,0 +1,112 @@ +import { Alert, StyleSheet, Text, View } from 'react-native'; +import ScreenContainer from '../../components/ScreenContainer'; +import PrimaryButton from '../../components/PrimaryButton'; +import { useAuth } from '../../context/AuthContext'; +import { useSync } from '../../context/SyncContext'; +import { colors } from '../../theme/colors'; + +export default function DashboardScreen() { + const { user } = useAuth(); + const { documents, certificates, signedDocuments, syncNow, syncing, lastSyncAt } = useSync(); + + const handleSync = async () => { + try { + await syncNow(); + Alert.alert('Sincronización', 'Los datos del teléfono fueron sincronizados con el backend.'); + } catch (error) { + Alert.alert('Sincronización', error.response?.data?.error || 'No se pudo sincronizar.'); + } + }; + + return ( + + + Hola, {user?.name || user?.email} + LinkedSign móvil comparte autenticación con la app web y sincroniza datos bajo petición. + + + + + {documents.length} + Documentos + + + {certificates.length} + Certificados + + + {signedDocuments.length} + Firmados + + + + + Sincronización local + + La base local del teléfono se actualiza solo cuando tú lo pides. La sesión permanece guardada de forma segura. + + + Última sincronización: {lastSyncAt ? new Date(lastSyncAt).toLocaleString('es-EC') : 'Sin sincronizar'} + + + + + ); +} + +const styles = StyleSheet.create({ + hero: { + backgroundColor: colors.dark, + borderRadius: 20, + padding: 20, + gap: 8, + }, + greeting: { + color: '#fff', + fontSize: 24, + fontWeight: '800', + }, + subtext: { + color: '#cbd5e1', + lineHeight: 20, + }, + statsGrid: { + flexDirection: 'row', + gap: 12, + }, + statCard: { + flex: 1, + backgroundColor: colors.surface, + borderRadius: 18, + padding: 16, + alignItems: 'center', + }, + statValue: { + fontSize: 28, + fontWeight: '800', + color: colors.primary, + }, + statLabel: { + color: colors.textMuted, + marginTop: 4, + }, + card: { + backgroundColor: colors.surface, + borderRadius: 18, + padding: 18, + gap: 10, + }, + cardTitle: { + fontSize: 18, + fontWeight: '700', + color: colors.text, + }, + cardText: { + color: colors.textMuted, + lineHeight: 20, + }, + syncMeta: { + color: colors.text, + fontSize: 13, + }, +}); diff --git a/android_react/src/screens/app/DocumentsScreen.js b/android_react/src/screens/app/DocumentsScreen.js new file mode 100644 index 0000000..2bf0583 --- /dev/null +++ b/android_react/src/screens/app/DocumentsScreen.js @@ -0,0 +1,153 @@ +import { Alert, FlatList, StyleSheet, Text, View } from 'react-native'; +import * as DocumentPicker from 'expo-document-picker'; +import ScreenContainer from '../../components/ScreenContainer'; +import PrimaryButton from '../../components/PrimaryButton'; +import { colors } from '../../theme/colors'; +import { useAuth } from '../../context/AuthContext'; +import { useSync } from '../../context/SyncContext'; +import { uploadDocument } from '../../api/documents'; +import { downloadAndShareFile } from '../../utils/files'; +import { API_BASE_URL } from '../../config/env'; + +function SectionCard({ title, rows, emptyText, actionLabel, onActionPress, onDownload, token, type }) { + return ( + + {title} + {actionLabel ? : null} + {rows.length === 0 ? ( + {emptyText} + ) : ( + `${type}-${item.id}`} + scrollEnabled={false} + ItemSeparatorComponent={() => } + renderItem={({ item }) => ( + + {item.title || item.signed_filename} + + {item.status || item.document_title || 'Documento'} + + onDownload(item, token)} + /> + + )} + /> + )} + + ); +} + +export default function DocumentsScreen() { + const { tokens } = useAuth(); + const { documents, signedDocuments, syncNow, refreshLocalCache } = useSync(); + + const handleUpload = async () => { + const picked = await DocumentPicker.getDocumentAsync({ + type: 'application/pdf', + copyToCacheDirectory: true, + }); + + if (picked.canceled) return; + const file = picked.assets[0]; + const formData = new FormData(); + formData.append('file', { + uri: file.uri, + name: file.name, + type: 'application/pdf', + }); + + try { + await uploadDocument(formData); + await syncNow(); + refreshLocalCache(); + Alert.alert('Documentos', 'PDF subido correctamente.'); + } catch (error) { + Alert.alert('Documentos', error.response?.data?.error || 'No se pudo subir el PDF.'); + } + }; + + const handleSync = async () => { + try { + await syncNow(); + Alert.alert('Documentos', 'Documentos sincronizados.'); + } catch (error) { + Alert.alert('Documentos', error.response?.data?.error || 'No se pudo sincronizar.'); + } + }; + + const handleDownloadOriginal = async (item) => { + await downloadAndShareFile({ + url: `${API_BASE_URL}/documents/${item.id}/download`, + token: tokens.accessToken, + filename: item.filename || `${item.title}.pdf`, + }); + }; + + const handleDownloadSigned = async (item) => { + await downloadAndShareFile({ + url: `${API_BASE_URL}/signatures/${item.id}/download`, + token: tokens.accessToken, + filename: item.signed_filename, + }); + }; + + return ( + + + + + + ); +} + +const styles = StyleSheet.create({ + section: { + backgroundColor: colors.surface, + borderRadius: 18, + padding: 16, + gap: 12, + }, + sectionTitle: { + fontSize: 18, + fontWeight: '700', + color: colors.text, + }, + emptyText: { + color: colors.textMuted, + }, + itemCard: { + borderWidth: 1, + borderColor: colors.border, + borderRadius: 14, + padding: 14, + gap: 8, + }, + itemTitle: { + color: colors.text, + fontWeight: '700', + }, + itemMeta: { + color: colors.textMuted, + fontSize: 12, + }, +}); diff --git a/android_react/src/screens/app/ProfileScreen.js b/android_react/src/screens/app/ProfileScreen.js new file mode 100644 index 0000000..4ad1bfa --- /dev/null +++ b/android_react/src/screens/app/ProfileScreen.js @@ -0,0 +1,104 @@ +import { Alert, StyleSheet, Text, TextInput, View } from 'react-native'; +import { useMemo, useState } from 'react'; +import ScreenContainer from '../../components/ScreenContainer'; +import PrimaryButton from '../../components/PrimaryButton'; +import PasswordRules from '../../components/PasswordRules'; +import { useAuth } from '../../context/AuthContext'; +import { useSync } from '../../context/SyncContext'; +import { isPasswordStrong } from '../../utils/passwordRules'; +import { colors } from '../../theme/colors'; + +export default function ProfileScreen() { + const { user, logout, requestPasswordChange } = useAuth(); + const { lastSyncAt, syncNow, syncing } = useSync(); + const [form, setForm] = useState({ + currentPassword: '', + newPassword: '', + confirmPassword: '', + }); + const [saving, setSaving] = useState(false); + + const syncLabel = useMemo( + () => (lastSyncAt ? new Date(lastSyncAt).toLocaleString('es-EC') : 'Sin sincronizar'), + [lastSyncAt] + ); + + const updateField = (name, value) => setForm((current) => ({ ...current, [name]: value })); + + const handlePasswordChange = async () => { + if (!isPasswordStrong(form.newPassword)) { + Alert.alert('Seguridad', 'La nueva contraseña no cumple con los requisitos.'); + return; + } + + if (form.newPassword !== form.confirmPassword) { + Alert.alert('Seguridad', 'Las nuevas contraseñas no coinciden.'); + return; + } + + setSaving(true); + try { + await requestPasswordChange({ + currentPassword: form.currentPassword, + newPassword: form.newPassword, + }); + setForm({ currentPassword: '', newPassword: '', confirmPassword: '' }); + Alert.alert('Perfil', 'Te enviamos un correo para confirmar el cambio de contraseña.'); + } catch (error) { + Alert.alert('Perfil', error.response?.data?.error || 'No se pudo solicitar el cambio de contraseña.'); + } finally { + setSaving(false); + } + }; + + return ( + + + Perfil + Correo: {user?.email} + Rol: {user?.role} + Última sincronización: {syncLabel} + + + + + Solicitar cambio de contraseña + updateField('currentPassword', value)} /> + updateField('newPassword', value)} /> + + updateField('confirmPassword', value)} /> + + + + + + ); +} + +const styles = StyleSheet.create({ + card: { + backgroundColor: colors.surface, + borderRadius: 18, + padding: 16, + gap: 10, + }, + title: { + fontSize: 18, + fontWeight: '700', + color: colors.text, + }, + label: { + fontWeight: '700', + }, + item: { + color: colors.textMuted, + }, + input: { + borderWidth: 1, + borderColor: colors.border, + borderRadius: 12, + paddingHorizontal: 14, + paddingVertical: 12, + backgroundColor: '#fff', + }, +}); diff --git a/android_react/src/screens/app/SignScreen.js b/android_react/src/screens/app/SignScreen.js new file mode 100644 index 0000000..c7fab6b --- /dev/null +++ b/android_react/src/screens/app/SignScreen.js @@ -0,0 +1,156 @@ +import { Alert, StyleSheet, Text, TextInput, View } from 'react-native'; +import { useEffect, useMemo, useState } from 'react'; +import ScreenContainer from '../../components/ScreenContainer'; +import PrimaryButton from '../../components/PrimaryButton'; +import { colors } from '../../theme/colors'; +import { useAuth } from '../../context/AuthContext'; +import { useSync } from '../../context/SyncContext'; +import { signDocument } from '../../api/signatures'; + +const selectFirst = (rows) => (rows.length > 0 ? String(rows[0].id) : ''); + +export default function SignScreen() { + const { user } = useAuth(); + const { documents, certificates, syncNow } = useSync(); + const [form, setForm] = useState({ + documentId: selectFirst(documents), + certificateId: selectFirst(certificates), + certPassword: '', + reason: 'Firmado digitalmente', + location: '', + }); + const [loading, setLoading] = useState(false); + + const activeCertificates = useMemo( + () => certificates.filter((certificate) => certificate.is_active === 1 || certificate.is_active === true), + [certificates] + ); + + useEffect(() => { + if (!form.documentId && documents.length > 0) { + setForm((current) => ({ ...current, documentId: String(documents[0].id) })); + } + }, [documents, form.documentId]); + + useEffect(() => { + if (!form.certificateId && activeCertificates.length > 0) { + setForm((current) => ({ ...current, certificateId: String(activeCertificates[0].id) })); + } + }, [activeCertificates, form.certificateId]); + + const handleSign = async () => { + if (!form.documentId || !form.certificateId) { + Alert.alert('Firmar', 'Selecciona un documento y un certificado sincronizados.'); + return; + } + + setLoading(true); + try { + await signDocument({ + documentId: Number(form.documentId), + certificateId: Number(form.certificateId), + certPassword: form.certPassword, + reason: form.reason, + location: form.location, + signatureStyle: user?.signaturePreferences?.signatureStyle || 'qr', + qrPosition: user?.signaturePreferences?.qrPosition || 'left', + qrBackground: user?.signaturePreferences?.qrBackground || 'transparent', + qrBorder: user?.signaturePreferences?.qrBorder || 'none', + signatureImageBase64: user?.signaturePreferences?.signatureImageBase64 || null, + }); + await syncNow(); + Alert.alert('Firmar', 'Documento firmado. Se usó la posición visible por defecto del backend.'); + } catch (error) { + Alert.alert('Firmar', error.response?.data?.error || 'No se pudo firmar el documento.'); + } finally { + setLoading(false); + } + }; + + return ( + + + Firma básica desde Android + Esta versión usa la configuración visual guardada y la posición por defecto del backend. + + Documento + setForm((current) => ({ ...current, documentId: value }))} + placeholder="ID de documento sincronizado" + /> + + Disponibles: {documents.map((item) => `${item.id}`).join(', ') || 'sin documentos'} + + + Certificado + setForm((current) => ({ ...current, certificateId: value }))} + placeholder="ID de certificado" + /> + + Disponibles: {activeCertificates.map((item) => `${item.id}`).join(', ') || 'sin certificados'} + + + setForm((current) => ({ ...current, certPassword: value }))} + placeholder="Contraseña del certificado (si aplica)" + secureTextEntry + /> + setForm((current) => ({ ...current, reason: value }))} + placeholder="Razón" + /> + setForm((current) => ({ ...current, location: value }))} + placeholder="Ubicación" + /> + + + + ); +} + +const styles = StyleSheet.create({ + card: { + backgroundColor: colors.surface, + borderRadius: 18, + padding: 16, + gap: 10, + }, + title: { + fontSize: 18, + fontWeight: '700', + color: colors.text, + }, + subtitle: { + color: colors.textMuted, + lineHeight: 20, + }, + label: { + fontWeight: '600', + color: colors.text, + marginTop: 4, + }, + input: { + borderWidth: 1, + borderColor: colors.border, + borderRadius: 12, + paddingHorizontal: 14, + paddingVertical: 12, + backgroundColor: '#fff', + }, + hint: { + color: colors.textMuted, + fontSize: 12, + }, +}); diff --git a/android_react/src/screens/auth/LoginScreen.js b/android_react/src/screens/auth/LoginScreen.js new file mode 100644 index 0000000..c6a0d18 --- /dev/null +++ b/android_react/src/screens/auth/LoginScreen.js @@ -0,0 +1,106 @@ +import { Alert, Pressable, StyleSheet, Text, TextInput, View } from 'react-native'; +import ScreenContainer from '../../components/ScreenContainer'; +import PrimaryButton from '../../components/PrimaryButton'; +import { colors } from '../../theme/colors'; +import { useState } from 'react'; +import { useAuth } from '../../context/AuthContext'; + +export default function LoginScreen({ navigation }) { + const { login } = useAuth(); + const [form, setForm] = useState({ email: '', password: '' }); + const [loading, setLoading] = useState(false); + + const updateField = (name, value) => setForm((current) => ({ ...current, [name]: value })); + + const handleLogin = async () => { + setLoading(true); + try { + await login(form.email, form.password); + } catch (error) { + const code = error.response?.data?.code; + const message = error.response?.data?.error || 'No se pudo iniciar sesión'; + if (code === 'EMAIL_NOT_VERIFIED') { + Alert.alert('Correo pendiente', 'Tu cuenta aún no está validada. Te enviamos un nuevo correo de confirmación.'); + } else if (code === 'MAIL_SEND_FAILED') { + Alert.alert('Correo no enviado', message); + } else { + Alert.alert('Iniciar sesión', message); + } + } finally { + setLoading(false); + } + }; + + return ( + + + LinkedSign + Firma digital segura desde Android + + + + Iniciar sesión + updateField('email', value)} + /> + updateField('password', value)} + /> + + + + navigation.navigate('Register')}> + ¿No tienes cuenta? Regístrate + + + ); +} + +const styles = StyleSheet.create({ + header: { + paddingTop: 24, + gap: 4, + }, + brand: { + fontSize: 28, + fontWeight: '800', + color: colors.text, + }, + subtitle: { + color: colors.textMuted, + fontSize: 14, + }, + card: { + backgroundColor: colors.surface, + borderRadius: 20, + padding: 20, + gap: 12, + }, + title: { + fontSize: 20, + fontWeight: '700', + color: colors.text, + }, + input: { + borderWidth: 1, + borderColor: colors.border, + borderRadius: 12, + paddingHorizontal: 14, + paddingVertical: 12, + backgroundColor: '#fff', + }, + link: { + textAlign: 'center', + color: colors.primary, + fontWeight: '600', + }, +}); diff --git a/android_react/src/screens/auth/RegisterScreen.js b/android_react/src/screens/auth/RegisterScreen.js new file mode 100644 index 0000000..ba8be0c --- /dev/null +++ b/android_react/src/screens/auth/RegisterScreen.js @@ -0,0 +1,107 @@ +import { Alert, Pressable, StyleSheet, Text, TextInput, View } from 'react-native'; +import { useState } from 'react'; +import ScreenContainer from '../../components/ScreenContainer'; +import PrimaryButton from '../../components/PrimaryButton'; +import PasswordRules from '../../components/PasswordRules'; +import { isPasswordStrong } from '../../utils/passwordRules'; +import { useAuth } from '../../context/AuthContext'; +import { colors } from '../../theme/colors'; + +export default function RegisterScreen({ navigation }) { + const { register } = useAuth(); + const [form, setForm] = useState({ email: '', password: '', confirmPassword: '' }); + const [loading, setLoading] = useState(false); + + const updateField = (name, value) => setForm((current) => ({ ...current, [name]: value })); + + const handleRegister = async () => { + if (!isPasswordStrong(form.password)) { + Alert.alert('Contraseña insegura', 'Tu contraseña no cumple con los requisitos mínimos.'); + return; + } + + if (form.password !== form.confirmPassword) { + Alert.alert('Confirmación', 'Las contraseñas no coinciden.'); + return; + } + + setLoading(true); + try { + await register({ email: form.email, password: form.password }); + navigation.replace('VerifyNotice', { email: form.email }); + } catch (error) { + Alert.alert('Registro', error.response?.data?.error || 'No se pudo registrar la cuenta.'); + } finally { + setLoading(false); + } + }; + + return ( + + + Crear cuenta + Regístrate con correo y contraseña segura. + + updateField('email', value)} + /> + updateField('password', value)} + /> + + updateField('confirmPassword', value)} + /> + + + + navigation.goBack()}> + Volver al inicio de sesión + + + ); +} + +const styles = StyleSheet.create({ + card: { + backgroundColor: colors.surface, + borderRadius: 20, + padding: 20, + gap: 12, + }, + title: { + fontSize: 20, + fontWeight: '700', + color: colors.text, + }, + subtitle: { + fontSize: 14, + color: colors.textMuted, + }, + input: { + borderWidth: 1, + borderColor: colors.border, + borderRadius: 12, + paddingHorizontal: 14, + paddingVertical: 12, + backgroundColor: '#fff', + }, + link: { + textAlign: 'center', + color: colors.primary, + fontWeight: '600', + }, +}); diff --git a/android_react/src/screens/auth/VerifyNoticeScreen.js b/android_react/src/screens/auth/VerifyNoticeScreen.js new file mode 100644 index 0000000..46237b1 --- /dev/null +++ b/android_react/src/screens/auth/VerifyNoticeScreen.js @@ -0,0 +1,38 @@ +import { StyleSheet, Text, View } from 'react-native'; +import ScreenContainer from '../../components/ScreenContainer'; +import PrimaryButton from '../../components/PrimaryButton'; +import { colors } from '../../theme/colors'; + +export default function VerifyNoticeScreen({ navigation, route }) { + const email = route.params?.email || ''; + + return ( + + + Confirma tu cuenta + + Enviamos un enlace de validación a {email || 'tu correo'}. Debes confirmarlo antes de iniciar sesión. + + navigation.navigate('Login')} /> + + + ); +} + +const styles = StyleSheet.create({ + card: { + backgroundColor: colors.surface, + borderRadius: 20, + padding: 20, + gap: 12, + }, + title: { + fontSize: 22, + fontWeight: '700', + color: colors.text, + }, + text: { + color: colors.textMuted, + lineHeight: 22, + }, +}); diff --git a/android_react/src/theme/colors.js b/android_react/src/theme/colors.js new file mode 100644 index 0000000..808578e --- /dev/null +++ b/android_react/src/theme/colors.js @@ -0,0 +1,13 @@ +export const colors = { + primary: '#4f46e5', + primaryDark: '#3730a3', + success: '#16a34a', + danger: '#dc2626', + warning: '#d97706', + text: '#111827', + textMuted: '#6b7280', + border: '#e5e7eb', + surface: '#ffffff', + background: '#f3f4f6', + dark: '#111827', +}; diff --git a/android_react/src/utils/files.js b/android_react/src/utils/files.js new file mode 100644 index 0000000..41cfd98 --- /dev/null +++ b/android_react/src/utils/files.js @@ -0,0 +1,17 @@ +import * as FileSystem from 'expo-file-system'; +import * as Sharing from 'expo-sharing'; + +export const downloadAndShareFile = async ({ url, token, filename }) => { + const targetUri = `${FileSystem.cacheDirectory}${filename}`; + const result = await FileSystem.downloadAsync(url, targetUri, { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + if (await Sharing.isAvailableAsync()) { + await Sharing.shareAsync(result.uri); + } + + return result.uri; +}; diff --git a/android_react/src/utils/passwordRules.js b/android_react/src/utils/passwordRules.js new file mode 100644 index 0000000..7dc599b --- /dev/null +++ b/android_react/src/utils/passwordRules.js @@ -0,0 +1,10 @@ +export const getPasswordChecks = (password = '') => ([ + { key: 'length', label: 'Al menos 8 caracteres', valid: password.length >= 8 }, + { key: 'upper', label: 'Una mayúscula', valid: /[A-Z]/.test(password) }, + { key: 'lower', label: 'Una minúscula', valid: /[a-z]/.test(password) }, + { key: 'number', label: 'Un número', valid: /\d/.test(password) }, + { key: 'special', label: 'Un carácter especial', valid: /[^A-Za-z0-9]/.test(password) }, +]); + +export const isPasswordStrong = (password = '') => + getPasswordChecks(password).every((rule) => rule.valid); diff --git a/apk/README.md b/apk/README.md new file mode 100644 index 0000000..3b05184 --- /dev/null +++ b/apk/README.md @@ -0,0 +1,48 @@ +# APK LinkedSign + +Esta carpeta está reservada para los instaladores Android generados desde el proyecto nativo en [android](/home/mgonzalez/desarrollo/react/linkedsign/android). + +## Estado actual + +En este entorno no fue posible generar el `.apk` porque faltan dos piezas del toolchain Android: + +- Android SDK +- Gradle wrapper funcional para el proyecto nativo + +Por eso aquí no hay todavía un instalador real. + +## Generar el APK + +Abre [android](/home/mgonzalez/desarrollo/react/linkedsign/android) en Android Studio o usa una máquina con Android SDK configurado y ejecuta: + +```bash +cd /home/mgonzalez/desarrollo/react/linkedsign/android +./gradlew assembleDebug +``` + +El archivo generado quedará normalmente en: + +```text +android/app/build/outputs/apk/debug/app-debug.apk +``` + +Luego puedes copiarlo a esta carpeta: + +```bash +cp /home/mgonzalez/desarrollo/react/linkedsign/android/app/build/outputs/apk/debug/app-debug.apk \ + /home/mgonzalez/desarrollo/react/linkedsign/apk/LinkedSign-debug.apk +``` + +## Instalación en teléfono + +1. Copia `LinkedSign-debug.apk` al teléfono +2. Habilita instalación desde orígenes permitidos +3. Instala el APK + +## Siguiente paso recomendado + +Cuando tengas Android Studio y SDK listos, el siguiente bloque útil es: + +- sincronizar Gradle +- compilar `debug` +- luego generar también `release` diff --git a/apk/build-apk.sh b/apk/build-apk.sh new file mode 100755 index 0000000..cb1d674 --- /dev/null +++ b/apk/build-apk.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="/home/mgonzalez/desarrollo/react/linkedsign" +ANDROID_DIR="$ROOT_DIR/android" +APK_DIR="$ROOT_DIR/apk" +SOURCE_APK="$ANDROID_DIR/app/build/outputs/apk/debug/app-debug.apk" +TARGET_APK="$APK_DIR/LinkedSign-debug.apk" + +cd "$ANDROID_DIR" +./gradlew assembleDebug + +mkdir -p "$APK_DIR" +cp "$SOURCE_APK" "$TARGET_APK" + +echo "APK generado en: $TARGET_APK" diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..dfaa308 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,20 @@ +PORT=4000 +CORS_ORIGIN=http://localhost:5173 +NODE_ENV=development +DB_HOST=127.0.0.1 +DB_PORT=3306 +DB_NAME=signdb +DB_USER=SignLinked +DB_PASSWORD= +JWT_SECRET=change_me +JWT_ACCESS_EXPIRES=15m +JWT_REFRESH_EXPIRES=7d +STORAGE_PATH=/tmp/linkedsign_storage +MAIL_HOST=smtp.gmail.com +MAIL_PORT=587 +MAIL_SECURE=false +MAIL_TLS_REJECT_UNAUTHORIZED=true +MAIL_USER= +MAIL_PASS= +MAIL_FROM=noreply@linkedsign.local +FRONTEND_URL=http://localhost:5173 diff --git a/backend/IMPLEMENTATION_SUMMARY.md b/backend/IMPLEMENTATION_SUMMARY.md new file mode 100644 index 0000000..8b39479 --- /dev/null +++ b/backend/IMPLEMENTATION_SUMMARY.md @@ -0,0 +1,145 @@ +# LinkedSign Backend - Resumen Actual + +Resumen corto del estado actual del backend que usan la web y la app Android. + +## Módulos activos + +- `auth` +- `users` +- `documents` +- `certificates` +- `signatures` +- `mail` +- `audit` +- `health` + +## Seguridad aplicada + +- `helmet` +- `cors` restringido +- rate limit en login +- JWT access + refresh +- contraseñas con `bcrypt` +- validación fuerte de contraseña +- validación de PDF +- control de acceso por usuario autenticado +- auditoría de login, documentos, certificados y firmas + +## Autenticación + +- registro con correo + contraseña +- validación del correo por enlace +- reenvío automático del correo si el usuario intenta entrar sin validar +- `GET /auth/me` +- logout y refresh token +- cambio de contraseña con confirmación por correo + +## Documentos + +- subida de PDF original +- listado +- descarga +- eliminación +- cálculo de hash SHA-256 del documento + +Ruta de almacenamiento: + +```text +/storage/documentos/YYYY/MM/ +``` + +## Certificados + +- subida de `.p12` / `.pfx` +- lectura de sujeto, emisor y vigencia +- detección de cantidad de certificados embebidos +- indicador de cadena embebida +- listado y eliminación + +Ruta de almacenamiento: + +```text +/backend/storage/certificates/users/{userId} +``` + +## Firmas + +- firmado de PDF con certificado del usuario +- estilos visuales: + - `classic` + - `qr` + - `professional` + - `image` +- QR configurable: + - izquierda / derecha + - borde / sin borde + - fondo suave / transparente +- nombre del firmado: + - `nombreOriginal_yyyymmddhhmm.pdf` +- listado de documentos firmados +- descarga del firmado +- verificación pública por QR + +## Verificación QR + +Cada firma puede generar: + +- `verificationCode` +- `verificationUrl` + +El QR apunta a la verificación pública de LinkedSign y muestra: + +- estado de validez interna +- firmante +- emisor del certificado +- documento +- fecha de firma +- razón y ubicación +- hash de integridad + +## Preferencias visuales + +Se guardan por usuario: + +- `signatureStyle` +- `qrPosition` +- `qrBackground` +- `qrBorder` +- `signatureImageBase64` + +Persistencia actual: + +```text +backend/storage/preferences/signature-styles.json +``` + +## Auditoría + +Eventos almacenados en: + +```text +backend/logs/audit.log +``` + +Se registran, entre otros: + +- `USER_REGISTERED` +- `LOGIN_SUCCESS` +- `LOGIN_FAILED` +- `VERIFICATION_EMAIL_RESENT` +- `PASSWORD_CHANGE_REQUESTED` +- `PASSWORD_CHANGED` +- `DOCUMENT_UPLOAD` +- `DOCUMENT_DELETE` +- `CERTIFICATE_UPLOAD` +- `CERTIFICATE_DELETE` +- `DOCUMENT_SIGNED` + +## Integración con Android + +La app móvil en [android/README.md](/home/mgonzalez/desarrollo/react/linkedsign/android/README.md): + +- usa esta misma API +- comparte autenticación con la web +- guarda sesión en el dispositivo +- sincroniza documentos, certificados y firmados bajo petición del usuario diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..02008db --- /dev/null +++ b/backend/README.md @@ -0,0 +1,210 @@ +# LinkedSign Backend + +Backend REST de LinkedSign construido con Node.js + Express. Esta API atiende tanto a la aplicación web como a la app Android. + +## Stack + +- Node.js + Express +- MariaDB +- JWT access + refresh +- `helmet`, `cors`, `morgan` +- `multer` para archivos +- `bcrypt` para contraseñas + +## Ejecutar + +```bash +cd /home/mgonzalez/desarrollo/react/linkedsign/backend +npm install +npm run dev +``` + +Producción: + +```bash +npm start +``` + +## Variables principales + +Usa [`.env.example`](/home/mgonzalez/desarrollo/react/linkedsign/backend/.env.example) como base. + +Variables importantes: + +```env +PORT=4000 +CORS_ORIGIN=http://192.168.222.72:5173 +FRONTEND_URL=http://192.168.222.72:5173 +MAIL_HOST= +MAIL_PORT=587 +MAIL_SECURE=false +MAIL_USER= +MAIL_PASS= +MAIL_FROM= +MAIL_TLS_REJECT_UNAUTHORIZED=false +JWT_ACCESS_SECRET= +JWT_REFRESH_SECRET= +DB_HOST= +DB_PORT=3306 +DB_USER= +DB_PASSWORD= +DB_NAME= +``` + +## Flujo actual + +- registro por correo y contraseña segura +- validación del correo por enlace +- reenvío automático del enlace si el usuario intenta entrar sin validar +- cambio de contraseña con confirmación por correo +- gestión de documentos PDF originales +- gestión de certificados P12/PFX +- firmado visible y criptográfico +- verificación pública por QR +- auditoría de eventos +- preferencias visuales de firma por usuario + +## Endpoints principales + +### Auth + +#### `POST /auth/register` + +Registra usuario y envía correo de validación. + +```bash +curl -X POST http://192.168.222.72:4000/auth/register \ + -H "Content-Type: application/json" \ + -d '{ + "email": "usuario@correo.com", + "password": "ClaveSegura#2026" + }' +``` + +#### `POST /auth/login` + +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 \ + -H "Content-Type: application/json" \ + -d '{ + "email": "usuario@correo.com", + "password": "ClaveSegura#2026" + }' +``` + +#### `GET /auth/verify/:token` + +Confirma el correo de la cuenta. + +#### `PATCH /auth/change-password` + +Solicita cambio de contraseña para usuario autenticado. Envía correo con enlace de confirmación. + +#### `GET /auth/confirm-password-change/:token` + +Aplica el cambio de contraseña desde el enlace enviado por correo. + +#### `GET /auth/me` + +Devuelve usuario autenticado y preferencias visuales de firma. + +### Documents + +#### `POST /documents` + +Sube un PDF original. + +```bash +curl -X POST http://192.168.222.72:4000/documents \ + -H "Authorization: Bearer TU_ACCESS_TOKEN" \ + -F "file=@/ruta/documento.pdf" +``` + +#### `GET /documents` + +Lista documentos originales del usuario. + +#### `GET /documents/:id/download` + +Descarga un documento original. + +### Certificates + +#### `POST /certificates` + +Sube un certificado `.p12` o `.pfx`. + +```bash +curl -X POST http://192.168.222.72:4000/certificates \ + -H "Authorization: Bearer TU_ACCESS_TOKEN" \ + -F "file=@/ruta/certificado.p12" \ + -F "alias=MPGH 2026" \ + -F "password=TuClaveP12" +``` + +#### `GET /certificates` + +Lista certificados del usuario, incluyendo datos de cadena embebida. + +### Signatures + +#### `POST /signatures/sign` + +Firma un documento con un certificado del usuario. + +```bash +curl -X POST http://192.168.222.72:4000/signatures/sign \ + -H "Authorization: Bearer TU_ACCESS_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "documentId": 1, + "certificateId": 2, + "certPassword": "TuClaveP12", + "reason": "Firmado digitalmente", + "location": "Guayaquil", + "page": 0, + "rect": null, + "signatureStyle": "qr", + "qrPosition": "right", + "qrBackground": "transparent", + "qrBorder": "none" + }' +``` + +#### `GET /signatures/mine` + +Lista PDFs firmados generados. + +#### `GET /signatures/:id/download` + +Descarga un PDF firmado. + +#### `GET /signatures/verify/:code` + +Verificación pública usada por el QR. + +### Users + +#### `PATCH /users/me/preferences/signature-appearance` + +Guarda la configuración visual de firma del usuario. + +## Respuesta estándar + +```json +{ + "success": true, + "data": {}, + "error": null +} +``` + +## Notas operativas + +- Si SMTP no está configurado, el flujo de correo no quedará completo. +- Si el servidor SMTP usa un certificado interno o vencido, puede ser necesario: + - `MAIL_TLS_REJECT_UNAUTHORIZED=false` +- La verificación QR de LinkedSign valida integridad y registro interno de la firma. +- La app Android en [android/README.md](/home/mgonzalez/desarrollo/react/linkedsign/android/README.md) consume esta misma API y sincroniza su cache local solo cuando el usuario lo solicita. diff --git a/backend/package-lock.json b/backend/package-lock.json new file mode 100644 index 0000000..7f4fcda --- /dev/null +++ b/backend/package-lock.json @@ -0,0 +1,1878 @@ +{ + "name": "linkedsign-backend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "linkedsign-backend", + "version": "0.1.0", + "dependencies": { + "@signpdf/placeholder-pdf-lib": "^3.3.0", + "@signpdf/signer-p12": "^3.3.0", + "@signpdf/signpdf": "^3.3.0", + "bcrypt": "^6.0.0", + "cors": "^2.8.5", + "dotenv": "^16.4.5", + "express": "^4.21.2", + "express-rate-limit": "^7.4.1", + "helmet": "^8.0.0", + "joi": "^18.1.2", + "jsonwebtoken": "^9.0.3", + "morgan": "^1.10.0", + "multer": "^2.1.1", + "mysql2": "^3.20.0", + "node-forge": "^1.3.3", + "nodemailer": "^8.0.4", + "pdf-lib": "^1.17.1", + "qrcode": "^1.5.4", + "uuid": "^13.0.0" + } + }, + "node_modules/@hapi/address": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@hapi/address/-/address-5.1.1.tgz", + "integrity": "sha512-A+po2d/dVoY7cYajycYI43ZbYMXukuopIsqCjh5QzsBCipDtdofHntljDlpccMjIfTy6UOkg+5KPriwYch2bXA==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^11.0.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@hapi/formula": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@hapi/formula/-/formula-3.0.2.tgz", + "integrity": "sha512-hY5YPNXzw1He7s0iqkRQi+uMGh383CGdyyIGYtB+W5N3KHPXoqychklvHhKCC9M3Xtv0OCs/IHw+r4dcHtBYWw==", + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/hoek": { + "version": "11.0.7", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-11.0.7.tgz", + "integrity": "sha512-HV5undWkKzcB4RZUusqOpcgxOaq6VOAH7zhhIr2g3G8NF/MlFO75SjOr2NfuSx0Mh40+1FqCkagKLJRykUWoFQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/pinpoint": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@hapi/pinpoint/-/pinpoint-2.0.1.tgz", + "integrity": "sha512-EKQmr16tM8s16vTT3cA5L0kZZcTMU5DUOZTuvpnY738m+jyP3JIUj+Mm1xc1rsLkGBQ/gVnfKYPwOmPg1tUR4Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/tlds": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@hapi/tlds/-/tlds-1.1.6.tgz", + "integrity": "sha512-xdi7A/4NZokvV0ewovme3aUO5kQhW9pQ2YD1hRqZGhhSi5rBv4usHYidVocXSi9eihYsznZxLtAiEYYUL6VBGw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@hapi/topo": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-6.0.2.tgz", + "integrity": "sha512-KR3rD5inZbGMrHmgPxsJ9dbi6zEK+C3ZwUwTa+eMwWLz7oijWUTWD2pMSNNYJAU6Qq+65NkxXjqHr/7LM2Xkqg==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^11.0.2" + } + }, + "node_modules/@pdf-lib/standard-fonts": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz", + "integrity": "sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==", + "license": "MIT", + "dependencies": { + "pako": "^1.0.6" + } + }, + "node_modules/@pdf-lib/upng": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@pdf-lib/upng/-/upng-1.0.1.tgz", + "integrity": "sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==", + "license": "MIT", + "dependencies": { + "pako": "^1.0.10" + } + }, + "node_modules/@signpdf/placeholder-pdf-lib": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@signpdf/placeholder-pdf-lib/-/placeholder-pdf-lib-3.3.0.tgz", + "integrity": "sha512-DT1s617z88NvvCmo+z8jaz7lNzy7b7/fwo/movJGukpfSkl4FaLNRXIHNBCTRCP9TUb6F/axK1JulES/jY7mLQ==", + "funding": [ + { + "type": "individual", + "url": "https://buymeacoffee.com/vbuch?utm_source=npm-funding-signpdf" + } + ], + "license": "MIT", + "dependencies": { + "@signpdf/utils": "^3.3.0" + }, + "engines": { + "node": ">=12", + "yarn": ">=1.22.18" + }, + "peerDependencies": { + "pdf-lib": "^1.17.1" + } + }, + "node_modules/@signpdf/signer-p12": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@signpdf/signer-p12/-/signer-p12-3.3.0.tgz", + "integrity": "sha512-MLynYKgUBu29ojIueIDV34sIOFDFpP0s1gQORxCc0zhs1jfy0juqU2xQpjUayMbhzB4L0zS9dPwgHtok1f89Cg==", + "funding": [ + { + "type": "individual", + "url": "https://buymeacoffee.com/vbuch?utm_source=npm-funding-signpdf" + } + ], + "license": "MIT", + "dependencies": { + "@signpdf/utils": "^3.3.0" + }, + "engines": { + "node": ">=12", + "yarn": ">=1.22.18" + }, + "peerDependencies": { + "node-forge": "^1.3.3" + } + }, + "node_modules/@signpdf/signpdf": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@signpdf/signpdf/-/signpdf-3.3.0.tgz", + "integrity": "sha512-/vy3dPTsTbLHdsKRarPQw8++UqN8GXxjEVnl4ZRl+o/zMWFqVb861RM5bxoRMGGMuPSvb1Rgt2lmVobEc0YUpw==", + "funding": [ + { + "type": "individual", + "url": "https://buymeacoffee.com/vbuch?utm_source=npm-funding-signpdf" + } + ], + "license": "MIT", + "dependencies": { + "@signpdf/utils": "^3.3.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@signpdf/utils": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@signpdf/utils/-/utils-3.3.0.tgz", + "integrity": "sha512-ArOP0MIhCfdChh1oZkog81TPeSY2e5TvHQmXJ682T4+SKb6Z609uAZhqMQZ9xm+YenEf/eNMSE5KVwU+EudXug==", + "funding": [ + { + "type": "individual", + "url": "https://buymeacoffee.com/vbuch?utm_source=npm-funding-signpdf" + } + ], + "license": "MIT", + "engines": { + "node": ">=12", + "yarn": ">=1.22.18" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.5.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.2.tgz", + "integrity": "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg==", + "license": "MIT", + "peer": true, + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/aws-ssl-profiles": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", + "integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/basic-auth": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", + "integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.1.2" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/basic-auth/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/bcrypt": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz", + "integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^8.3.0", + "node-gyp-build": "^4.8.4" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/body-parser": { + "version": "1.20.4", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", + "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.14.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "7.5.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz", + "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generate-function": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", + "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", + "license": "MIT", + "dependencies": { + "is-property": "^1.0.2" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/helmet": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz", + "integrity": "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-property": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", + "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", + "license": "MIT" + }, + "node_modules/joi": { + "version": "18.1.2", + "resolved": "https://registry.npmjs.org/joi/-/joi-18.1.2.tgz", + "integrity": "sha512-rF5MAmps5esSlhCA+N1b6IYHDw9j/btzGaqfgie522jS02Ju/HXBxamlXVlKEHAxoMKQL77HWI8jlqWsFuekZA==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/address": "^5.1.1", + "@hapi/formula": "^3.0.2", + "@hapi/hoek": "^11.0.7", + "@hapi/pinpoint": "^2.0.1", + "@hapi/tlds": "^1.1.1", + "@hapi/topo": "^6.0.2", + "@standard-schema/spec": "^1.1.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsonwebtoken/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/lru.min": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.4.tgz", + "integrity": "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==", + "license": "MIT", + "engines": { + "bun": ">=1.0.0", + "deno": ">=1.30.0", + "node": ">=8.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wellwelwel" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/morgan": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.1.tgz", + "integrity": "sha512-223dMRJtI/l25dJKWpgij2cMtywuG/WiUKXdvwfbhGKBhy1puASqXwFzmWZ7+K73vUPoR7SS2Qz2cI/g9MKw0A==", + "license": "MIT", + "dependencies": { + "basic-auth": "~2.0.1", + "debug": "2.6.9", + "depd": "~2.0.0", + "on-finished": "~2.3.0", + "on-headers": "~1.1.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/morgan/node_modules/on-finished": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", + "integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/multer": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.1.1.tgz", + "integrity": "sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "concat-stream": "^2.0.0", + "type-is": "^1.6.18" + }, + "engines": { + "node": ">= 10.16.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/mysql2": { + "version": "3.20.0", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.20.0.tgz", + "integrity": "sha512-eCLUs7BNbgA6nf/MZXsaBO1SfGs0LtLVrJD3WeWq+jPLDWkSufTD+aGMwykfUVPdZnblaUK1a8G/P63cl9FkKg==", + "license": "MIT", + "dependencies": { + "aws-ssl-profiles": "^1.1.2", + "denque": "^2.1.0", + "generate-function": "^2.3.1", + "iconv-lite": "^0.7.2", + "long": "^5.3.2", + "lru.min": "^1.1.4", + "named-placeholders": "^1.1.6", + "sql-escaper": "^1.3.3" + }, + "engines": { + "node": ">= 8.0" + }, + "peerDependencies": { + "@types/node": ">= 8" + } + }, + "node_modules/mysql2/node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/named-placeholders": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz", + "integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==", + "license": "MIT", + "dependencies": { + "lru.min": "^1.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-addon-api": { + "version": "8.7.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.7.0.tgz", + "integrity": "sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA==", + "license": "MIT", + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, + "node_modules/node-forge": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.3.tgz", + "integrity": "sha512-rLvcdSyRCyouf6jcOIPe/BgwG/d7hKjzMKOas33/pHEr6gbq18IK9zV7DiPvzsz0oBJPme6qr6H6kGZuI9/DZg==", + "license": "(BSD-3-Clause OR GPL-2.0)", + "engines": { + "node": ">= 6.13.0" + } + }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, + "node_modules/nodemailer": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.4.tgz", + "integrity": "sha512-k+jf6N8PfQJ0Fe8ZhJlgqU5qJU44Lpvp2yvidH3vp1lPnVQMgi4yEEMPXg5eJS1gFIJTVq1NHBk7Ia9ARdSBdQ==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "license": "MIT" + }, + "node_modules/pdf-lib": { + "version": "1.17.1", + "resolved": "https://registry.npmjs.org/pdf-lib/-/pdf-lib-1.17.1.tgz", + "integrity": "sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==", + "license": "MIT", + "dependencies": { + "@pdf-lib/standard-fonts": "^1.0.0", + "@pdf-lib/upng": "^1.0.1", + "pako": "^1.0.11", + "tslib": "^1.11.1" + } + }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/qs": { + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/sql-escaper": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/sql-escaper/-/sql-escaper-1.3.3.tgz", + "integrity": "sha512-BsTCV265VpTp8tm1wyIm1xqQCS+Q9NHx2Sr+WcnUrgLrQ6yiDIvHYJV5gHxsj1lMBy2zm5twLaZao8Jd+S8JJw==", + "license": "MIT", + "engines": { + "bun": ">=1.0.0", + "deno": ">=2.0.0", + "node": ">=12.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/mysqljs/sql-escaper?sponsor=1" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "license": "0BSD" + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "license": "MIT", + "peer": true + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/uuid": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", + "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, + "node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + } + } +} diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..707c4ca --- /dev/null +++ b/backend/package.json @@ -0,0 +1,32 @@ +{ + "name": "linkedsign-backend", + "version": "0.1.0", + "private": true, + "description": "Initial backend for LinkedSign", + "main": "src/server.js", + "scripts": { + "dev": "node --watch src/server.js", + "start": "node src/server.js" + }, + "dependencies": { + "@signpdf/placeholder-pdf-lib": "^3.3.0", + "@signpdf/signer-p12": "^3.3.0", + "@signpdf/signpdf": "^3.3.0", + "bcrypt": "^6.0.0", + "cors": "^2.8.5", + "dotenv": "^16.4.5", + "express": "^4.21.2", + "express-rate-limit": "^7.4.1", + "helmet": "^8.0.0", + "joi": "^18.1.2", + "jsonwebtoken": "^9.0.3", + "morgan": "^1.10.0", + "multer": "^2.1.1", + "mysql2": "^3.20.0", + "node-forge": "^1.3.3", + "nodemailer": "^8.0.4", + "pdf-lib": "^1.17.1", + "qrcode": "^1.5.4", + "uuid": "^13.0.0" + } +} diff --git a/backend/scripts/logrotate.sh b/backend/scripts/logrotate.sh new file mode 100755 index 0000000..dd4f7f9 --- /dev/null +++ b/backend/scripts/logrotate.sh @@ -0,0 +1,12 @@ +#!/bin/bash + +LOG_DIR="/home/mgonzalez/desarrollo/react/linkedsign/backend/logs" +LOG_FILE="$LOG_DIR/audit.log" +DATE=$(date +"%Y-%m-%d") + +if [ -f "$LOG_FILE" ]; then + mv "$LOG_FILE" "$LOG_DIR/audit-$DATE.log" + touch "$LOG_FILE" + chmod 600 "$LOG_FILE" +fi + diff --git a/backend/src/app.js b/backend/src/app.js new file mode 100644 index 0000000..f15bdc6 --- /dev/null +++ b/backend/src/app.js @@ -0,0 +1,29 @@ +const express = require('express'); +const helmet = require('helmet'); +const cors = require('cors'); +const morgan = require('morgan'); + +const env = require('./config/env'); +const routes = require('./routes'); +const authRateLimiter = require('./middlewares/authRateLimiter'); +const notFound = require('./middlewares/notFound'); +const errorHandler = require('./middlewares/errorHandler'); + +const app = express(); +app.set('trust proxy', 'loopback'); + +app.use(helmet()); +app.use( + cors({ + origin: env.corsOrigin, + }) +); +app.use(morgan(env.nodeEnv === 'production' ? 'combined' : 'dev')); +app.use(express.json()); + +app.use('/auth/login', authRateLimiter); +app.use(routes); +app.use(notFound); +app.use(errorHandler); + +module.exports = app; diff --git a/backend/src/config/db.js b/backend/src/config/db.js new file mode 100644 index 0000000..5ffaa8a --- /dev/null +++ b/backend/src/config/db.js @@ -0,0 +1,17 @@ +const mysql = require('mysql2/promise'); +const env = require('./env'); + +const pool = mysql.createPool({ + host: env.db.host, + port: env.db.port, + database: env.db.name, + user: env.db.user, + password: env.db.password, + waitForConnections: true, + connectionLimit: 10, + queueLimit: 0, + timezone: '+00:00', + charset: 'utf8mb4', +}); + +module.exports = pool; diff --git a/backend/src/config/env.js b/backend/src/config/env.js new file mode 100644 index 0000000..d8f1bd8 --- /dev/null +++ b/backend/src/config/env.js @@ -0,0 +1,45 @@ +const path = require('path'); +const dotenv = require('dotenv'); + +dotenv.config({ path: path.resolve(process.cwd(), '.env') }); + +const env = { + port: Number(process.env.PORT) || 4000, + nodeEnv: process.env.NODE_ENV || 'development', + corsOrigin: process.env.CORS_ORIGIN || 'http://localhost:5173', + + db: { + host: process.env.DB_HOST || '127.0.0.1', + port: Number(process.env.DB_PORT) || 3306, + name: process.env.DB_NAME || 'signdb', + user: process.env.DB_USER || 'SignLinked', + password: process.env.DB_PASSWORD || '', + }, + + jwt: { + secret: process.env.JWT_SECRET || 'change_me', + accessExpires: process.env.JWT_ACCESS_EXPIRES || '15m', + refreshExpires: process.env.JWT_REFRESH_EXPIRES || '7d', + }, + + storagePath: process.env.STORAGE_PATH || '/tmp/linkedsign_storage', + + p12: { + defaultPath: process.env.P12_DEFAULT_PATH || null, + defaultPassword: process.env.P12_DEFAULT_PASSWORD || '', + }, + + mail: { + host: process.env.MAIL_HOST || 'smtp.gmail.com', + port: Number(process.env.MAIL_PORT) || 587, + secure: process.env.MAIL_SECURE === 'true', + rejectUnauthorized: process.env.MAIL_TLS_REJECT_UNAUTHORIZED !== 'false', + user: process.env.MAIL_USER || '', + pass: process.env.MAIL_PASS || '', + from: process.env.MAIL_FROM || 'noreply@linkedsign.local', + }, + + frontendUrl: process.env.FRONTEND_URL || 'http://localhost:5173', +}; + +module.exports = env; diff --git a/backend/src/config/multer.js b/backend/src/config/multer.js new file mode 100644 index 0000000..8c7affa --- /dev/null +++ b/backend/src/config/multer.js @@ -0,0 +1,57 @@ +const path = require('path'); +const multer = require('multer'); +const { getDocumentPath, getCertificatePath } = require('../utils/storagePath'); + +const documentStorage = multer.diskStorage({ + destination: async (req, file, cb) => { + try { + cb(null, await getDocumentPath()); + } catch (err) { + cb(err); + } + }, + filename: (req, file, cb) => { + const safe = path.basename(file.originalname || 'document.pdf').replace(/[^a-zA-Z0-9._-]/g, '_'); + cb(null, `${Date.now()}-${safe}`); + }, +}); + +const certificateStorage = multer.diskStorage({ + destination: async (req, file, cb) => { + try { + const userId = req.user?.id || 'unknown'; + cb(null, await getCertificatePath(userId)); + } 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 }, + fileFilter: (req, file, cb) => { + if (file.mimetype !== 'application/pdf') { + return cb(new Error('Only PDF files are allowed.')); + } + cb(null, true); + }, +}); + +const uploadCertificate = multer({ + storage: certificateStorage, + 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 }; diff --git a/backend/src/middlewares/authRateLimiter.js b/backend/src/middlewares/authRateLimiter.js new file mode 100644 index 0000000..abe4317 --- /dev/null +++ b/backend/src/middlewares/authRateLimiter.js @@ -0,0 +1,15 @@ +const rateLimit = require('express-rate-limit'); + +const authRateLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, + max: 20, + standardHeaders: true, + legacyHeaders: false, + message: { + success: false, + data: null, + error: 'Too many login attempts. Please try again later.', + }, +}); + +module.exports = authRateLimiter; diff --git a/backend/src/middlewares/authenticate.js b/backend/src/middlewares/authenticate.js new file mode 100644 index 0000000..156bdd2 --- /dev/null +++ b/backend/src/middlewares/authenticate.js @@ -0,0 +1,19 @@ +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.' }); + } + + const token = authHeader.slice(7); + try { + const decoded = verify(token); + 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.' }); + } +}; + +module.exports = authenticate; diff --git a/backend/src/middlewares/errorHandler.js b/backend/src/middlewares/errorHandler.js new file mode 100644 index 0000000..cff0805 --- /dev/null +++ b/backend/src/middlewares/errorHandler.js @@ -0,0 +1,29 @@ +const { sendError } = require('../utils/apiResponse'); + +const errorHandler = (err, req, res, next) => { + if (res.headersSent) { + return next(err); + } + + const statusCode = err.statusCode || 500; + if (statusCode >= 500) { + console.error(`[ERROR ${statusCode}]`, err.message); + console.error(err.stack?.split('\n').slice(0, 6).join('\n')); + } + const message = err.code + ? (err.message || 'Request error') + : (statusCode >= 500 ? 'Internal server error' : err.message || 'Request error'); + + if (err.code) { + return res.status(statusCode).json({ + success: false, + data: err.data || null, + error: message, + code: err.code, + }); + } + + return sendError(res, message, statusCode); +}; + +module.exports = errorHandler; diff --git a/backend/src/middlewares/fileValidator.js b/backend/src/middlewares/fileValidator.js new file mode 100644 index 0000000..4aa3c7c --- /dev/null +++ b/backend/src/middlewares/fileValidator.js @@ -0,0 +1,28 @@ +const path = require('path'); + +const MAX_FILE_SIZE = 10 * 1024 * 1024; + +const fileValidator = (req, res, next) => { + const file = req.file; + + if (!file) { + const error = new Error('Invalid file upload'); + error.statusCode = 400; + return next(error); + } + + const extension = path.extname(file.originalname || '').toLowerCase(); + const isPdfMimeType = file.mimetype === 'application/pdf'; + const isPdfExtension = extension === '.pdf'; + const isValidSize = typeof file.size === 'number' && file.size <= MAX_FILE_SIZE; + + if (!isPdfMimeType || !isPdfExtension || !isValidSize) { + const error = new Error('Invalid file upload'); + error.statusCode = 400; + return next(error); + } + + return next(); +}; + +module.exports = fileValidator; diff --git a/backend/src/middlewares/notFound.js b/backend/src/middlewares/notFound.js new file mode 100644 index 0000000..ade4832 --- /dev/null +++ b/backend/src/middlewares/notFound.js @@ -0,0 +1,7 @@ +const { sendError } = require('../utils/apiResponse'); + +const notFound = (req, res) => { + return sendError(res, `Route not found: ${req.method} ${req.originalUrl}`, 404); +}; + +module.exports = notFound; diff --git a/backend/src/modules/audit/audit.model.js b/backend/src/modules/audit/audit.model.js new file mode 100644 index 0000000..0467b18 --- /dev/null +++ b/backend/src/modules/audit/audit.model.js @@ -0,0 +1,11 @@ +const auditModel = { + id: 'number', + type: 'string', + userId: 'number|null', + email: 'string|null', + ip: 'string', + userAgent: 'string', + timestamp: 'Date', +}; + +module.exports = auditModel; diff --git a/backend/src/modules/audit/audit.repository.js b/backend/src/modules/audit/audit.repository.js new file mode 100644 index 0000000..5df660e --- /dev/null +++ b/backend/src/modules/audit/audit.repository.js @@ -0,0 +1,44 @@ +const db = require('../../config/db'); +const fs = require('fs/promises'); +const path = require('path'); + +const saveEvent = async (event) => { + try { + await db.execute( + 'INSERT INTO audit_logs (type, user_id, email, ip, user_agent, metadata) VALUES (?, ?, ?, ?, ?, ?)', + [ + event.type, + event.userId || null, + event.email || null, + event.ip || null, + event.userAgent || null, + event.metadata ? JSON.stringify(event.metadata) : null, + ] + ); + } catch (err) { + // Fallback to file log if DB fails + try { + const logsDir = path.resolve(process.cwd(), 'logs'); + await fs.mkdir(logsDir, { recursive: true }); + await fs.appendFile( + path.resolve(logsDir, 'audit.log'), + `${JSON.stringify({ ...event, _fallback: true })}\n` + ); + } catch { + // Silent + } + } +}; + +const getEvents = async ({ type, userId, limit = 100, offset = 0 } = {}) => { + let sql = 'SELECT * FROM audit_logs WHERE 1=1'; + const params = []; + if (type) { sql += ' AND type = ?'; params.push(type); } + if (userId) { sql += ' AND user_id = ?'; params.push(userId); } + sql += ' ORDER BY created_at DESC LIMIT ? OFFSET ?'; + params.push(limit, offset); + const [rows] = await db.execute(sql, params); + return rows; +}; + +module.exports = { saveEvent, getEvents }; diff --git a/backend/src/modules/audit/audit.service.js b/backend/src/modules/audit/audit.service.js new file mode 100644 index 0000000..4344d7b --- /dev/null +++ b/backend/src/modules/audit/audit.service.js @@ -0,0 +1,11 @@ +const auditRepository = require('./audit.repository'); + +const logEvent = async ({ type, userId = null, email = null, ip = null, userAgent = null, metadata = null } = {}) => { + await auditRepository.saveEvent({ type, userId, email, ip, userAgent, metadata }); +}; + +const getEvents = async (filters) => { + return auditRepository.getEvents(filters); +}; + +module.exports = { logEvent, getEvents }; diff --git a/backend/src/modules/auth/auth.controller.js b/backend/src/modules/auth/auth.controller.js new file mode 100644 index 0000000..18d7a48 --- /dev/null +++ b/backend/src/modules/auth/auth.controller.js @@ -0,0 +1,90 @@ +const authService = require('./auth.service'); +const usersService = require('../users/users.service'); +const { sendSuccess } = require('../../utils/apiResponse'); + +const getContext = (req) => ({ + ip: req.ip?.replace('::ffff:', '') || null, + userAgent: req.headers['user-agent'] || null, +}); + +const register = async (req, res, next) => { + try { + const result = await authService.register(req.body); + return sendSuccess(res, { + message: 'Registration successful. Please check your email to verify your account.', + userId: result.userId, + }, 201); + } catch (err) { + return next(err); + } +}; + +const login = async (req, res, next) => { + try { + const result = await authService.login(req.body, getContext(req)); + return sendSuccess(res, result, 200); + } catch (err) { + return next(err); + } +}; + +const refresh = async (req, res, next) => { + try { + const result = await authService.refresh(req.body.refreshToken); + return sendSuccess(res, result, 200); + } catch (err) { + return next(err); + } +}; + +const logout = async (req, res, next) => { + try { + await authService.logout(req.body.refreshToken); + return sendSuccess(res, { message: 'Logged out.' }, 200); + } catch (err) { + return next(err); + } +}; + +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); + } catch (err) { + return next(err); + } +}; + +const confirmPasswordChange = async (req, res, next) => { + try { + await authService.confirmPasswordChange(req.params.token); + return sendSuccess(res, { message: 'Password changed successfully.' }, 200); + } catch (err) { + return next(err); + } +}; + +const verifyEmail = async (req, res, next) => { + try { + await authService.verifyEmail(req.params.token); + return sendSuccess(res, { message: 'Email verified successfully.' }, 200); + } catch (err) { + return next(err); + } +}; + +const me = async (req, res, next) => { + try { + const signaturePreferences = await usersService.getSignaturePreferences(req.user.id); + return sendSuccess(res, { + user: { + ...req.user, + signaturePreferences, + }, + }, 200); + } catch (err) { + return next(err); + } +}; + +module.exports = { register, login, refresh, logout, verifyEmail, me, changePassword, confirmPasswordChange }; diff --git a/backend/src/modules/auth/auth.repository.js b/backend/src/modules/auth/auth.repository.js new file mode 100644 index 0000000..5b4faff --- /dev/null +++ b/backend/src/modules/auth/auth.repository.js @@ -0,0 +1,115 @@ +const db = require('../../config/db'); + +const findByEmail = async (email) => { + const [rows] = await db.execute( + 'SELECT id, name, email, password_hash, role, is_verified, is_active FROM users WHERE email = ?', + [email] + ); + return rows[0] || null; +}; + +const findById = async (id) => { + const [rows] = await db.execute( + 'SELECT id, name, email, role, is_verified, is_active, created_at FROM users WHERE id = ?', + [id] + ); + return rows[0] || null; +}; + +const findAuthUserById = async (id) => { + const [rows] = await db.execute( + 'SELECT id, name, email, password_hash, role, is_verified, is_active FROM users WHERE id = ?', + [id] + ); + 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) + VALUES (?, ?, ?, ?, ?)`, + [name, email, passwordHash, verificationToken, verificationTokenExpires] + ); + return result.insertId; +}; + +const verifyEmail = async (token) => { + const [rows] = await db.execute( + 'SELECT id FROM users WHERE verification_token = ? AND verification_token_expires > NOW() AND is_verified = 0', + [token] + ); + if (!rows[0]) return null; + await db.execute( + 'UPDATE users SET is_verified = 1, verification_token = NULL, verification_token_expires = NULL WHERE id = ?', + [rows[0].id] + ); + return rows[0].id; +}; + +const updateVerificationToken = async (userId, token, expiresAt) => { + await db.execute( + 'UPDATE users SET verification_token = ?, verification_token_expires = ? WHERE id = ?', + [token, expiresAt, userId] + ); +}; + +const saveRefreshToken = async (userId, token, expiresAt) => { + await db.execute( + 'INSERT INTO refresh_tokens (user_id, token, expires_at) VALUES (?, ?, ?)', + [userId, token, expiresAt] + ); +}; + +const findRefreshToken = async (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] + ); + return rows[0] || null; +}; + +const deleteRefreshToken = async (token) => { + await db.execute('DELETE FROM refresh_tokens WHERE token = ?', [token]); +}; + +const deleteUserRefreshTokens = async (userId) => { + await db.execute('DELETE FROM refresh_tokens WHERE user_id = ?', [userId]); +}; + +const setResetToken = async (userId, token, expiresAt) => { + await db.execute( + 'UPDATE users SET reset_token = ?, reset_token_expires = ? WHERE id = ?', + [token, expiresAt, userId] + ); +}; + +const findByResetToken = async (token) => { + const [rows] = await db.execute( + 'SELECT id FROM users WHERE reset_token = ? AND reset_token_expires > NOW()', + [token] + ); + return rows[0] || null; +}; + +const updatePassword = async (userId, passwordHash) => { + await db.execute( + 'UPDATE users SET password_hash = ?, reset_token = NULL, reset_token_expires = NULL WHERE id = ?', + [passwordHash, userId] + ); +}; + +module.exports = { + findByEmail, + findById, + findAuthUserById, + createUser, + verifyEmail, + updateVerificationToken, + saveRefreshToken, + findRefreshToken, + deleteRefreshToken, + deleteUserRefreshTokens, + setResetToken, + findByResetToken, + updatePassword, +}; diff --git a/backend/src/modules/auth/auth.routes.js b/backend/src/modules/auth/auth.routes.js new file mode 100644 index 0000000..b79d896 --- /dev/null +++ b/backend/src/modules/auth/auth.routes.js @@ -0,0 +1,17 @@ +const express = require('express'); +const authController = require('./auth.controller'); +const { validate, registerSchema, loginSchema, refreshSchema, changePasswordSchema } = require('./auth.validator'); +const authenticate = require('../../middlewares/authenticate'); + +const router = express.Router(); + +router.post('/register', validate(registerSchema), authController.register); +router.post('/login', validate(loginSchema), authController.login); +router.post('/refresh', validate(refreshSchema), authController.refresh); +router.post('/logout', authController.logout); +router.get('/verify/:token', authController.verifyEmail); +router.get('/confirm-password-change/:token', authController.confirmPasswordChange); +router.get('/me', authenticate, authController.me); +router.patch('/change-password', authenticate, validate(changePasswordSchema), authController.changePassword); + +module.exports = router; diff --git a/backend/src/modules/auth/auth.service.js b/backend/src/modules/auth/auth.service.js new file mode 100644 index 0000000..dfef2ae --- /dev/null +++ b/backend/src/modules/auth/auth.service.js @@ -0,0 +1,286 @@ +const bcrypt = require('bcrypt'); +const { v4: uuidv4 } = require('uuid'); +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 { signAccess, signRefresh } = require('../../utils/jwt'); + +const SALT_ROUNDS = 12; + +const buildDisplayNameFromEmail = (email) => { + const localPart = String(email).split('@')[0] || 'Usuario'; + const normalized = localPart + .replace(/[._-]+/g, ' ') + .trim() + .slice(0, 100); + + return normalized || 'Usuario'; +}; + +const issueVerificationEmail = async (user) => { + const verificationToken = uuidv4(); + const verificationTokenExpires = new Date(Date.now() + 24 * 60 * 60 * 1000); + + await authRepository.updateVerificationToken(user.id, verificationToken, verificationTokenExpires); + + const mailResult = await mailService.sendVerificationEmail({ + name: user.name || buildDisplayNameFromEmail(user.email), + email: user.email, + verificationToken, + }); + + return { + verificationToken, + verificationUrl: mailResult.verificationUrl, + }; +}; + +const register = async ({ email, password }) => { + const existing = await authRepository.findByEmail(email); + if (existing) { + const err = new Error('Email already registered.'); + err.statusCode = 409; + throw err; + } + + const name = buildDisplayNameFromEmail(email); + const passwordHash = await bcrypt.hash(password, SALT_ROUNDS); + const verificationToken = uuidv4(); + const verificationTokenExpires = new Date(Date.now() + 24 * 60 * 60 * 1000); + + const userId = await authRepository.createUser({ + name, + email, + passwordHash, + verificationToken, + verificationTokenExpires, + }); + let mailResult; + try { + mailResult = await mailService.sendVerificationEmail({ + name, + email, + verificationToken, + }); + } 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'; + throw err; + } + + await auditService.logEvent({ + type: 'USER_REGISTERED', + userId, + email, + metadata: { + verificationEmailSent: true, + }, + }); + + return { userId, verificationToken, verificationUrl: mailResult.verificationUrl }; +}; + +const login = async ({ email, password }, context = {}) => { + const user = await authRepository.findByEmail(email.trim().toLowerCase()); + + if (!user || !user.is_active) { + await auditService.logEvent({ type: 'LOGIN_FAILED', email, ip: context.ip, userAgent: context.userAgent }); + const err = new Error('Invalid credentials.'); + err.statusCode = 401; + throw err; + } + + const passwordMatch = await bcrypt.compare(password, user.password_hash); + if (!passwordMatch) { + await auditService.logEvent({ type: 'LOGIN_FAILED', userId: user.id, email, ip: context.ip, userAgent: context.userAgent }); + const err = new Error('Invalid credentials.'); + err.statusCode = 401; + throw err; + } + + 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.'); + err.statusCode = 503; + err.code = 'MAIL_SEND_FAILED'; + throw err; + } + await auditService.logEvent({ + type: 'VERIFICATION_EMAIL_RESENT', + userId: user.id, + email: user.email, + ip: context.ip, + 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.'); + err.statusCode = 403; + err.code = 'EMAIL_NOT_VERIFIED'; + err.data = { verificationUrl }; + throw err; + } + + const payload = { sub: user.id, email: user.email, role: user.role, name: user.name }; + const accessToken = signAccess(payload); + const refreshToken = signRefresh(payload); + + const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); + await authRepository.saveRefreshToken(user.id, refreshToken, expiresAt); + + await auditService.logEvent({ type: 'LOGIN_SUCCESS', userId: user.id, email, ip: context.ip, userAgent: context.userAgent }); + + const signaturePreferences = await usersService.getSignaturePreferences(user.id); + + return { + accessToken, + refreshToken, + user: { + id: user.id, + name: user.name, + email: user.email, + role: user.role, + signaturePreferences, + }, + }; +}; + +const refresh = async (token) => { + const record = await authRepository.findRefreshToken(token); + if (!record) { + const err = new Error('Invalid or expired refresh token.'); + err.statusCode = 401; + throw err; + } + + if (!record.is_active) { + await authRepository.deleteUserRefreshTokens(record.user_id); + const err = new Error('Session invalidated.'); + err.statusCode = 401; + throw err; + } + + await authRepository.deleteRefreshToken(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); + await authRepository.saveRefreshToken(record.uid, newRefreshToken, expiresAt); + + return { accessToken, refreshToken: newRefreshToken }; +}; + +const logout = async (token) => { + await authRepository.deleteRefreshToken(token); +}; + +const changePassword = async (userId, { currentPassword, newPassword }, context = {}) => { + const user = await authRepository.findAuthUserById(userId); + if (!user || !user.is_active) { + const err = new Error('User not found.'); + err.statusCode = 404; + throw err; + } + + const passwordMatch = await bcrypt.compare(currentPassword, user.password_hash); + if (!passwordMatch) { + const err = new Error('Current password is incorrect.'); + err.statusCode = 401; + throw err; + } + + const isSamePassword = await bcrypt.compare(newPassword, user.password_hash); + if (isSamePassword) { + const err = new Error('The new password must be different from the current password.'); + 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, + email: user.email, + passwordHash, + expiresAt: expiresAt.toISOString(), + }); + + await mailService.sendPasswordChangeConfirmationEmail({ + name: user.name || buildDisplayNameFromEmail(user.email), + email: user.email, + confirmationToken, + }); + + await auditService.logEvent({ + type: 'PASSWORD_CHANGE_REQUESTED', + userId, + email: user.email, + ip: context.ip, + userAgent: context.userAgent, + }); + + return { confirmationToken }; +}; + +const confirmPasswordChange = async (token) => { + const request = await passwordChangeRepository.findRequest(token); + if (!request) { + const err = new Error('Invalid or expired password change token.'); + err.statusCode = 400; + throw err; + } + + if (new Date(request.expiresAt) < new Date()) { + await passwordChangeRepository.deleteRequest(token); + const err = new Error('Invalid or expired password change token.'); + err.statusCode = 400; + throw err; + } + + const user = await authRepository.findAuthUserById(request.userId); + if (!user || !user.is_active) { + await passwordChangeRepository.deleteRequest(token); + const err = new Error('User not found.'); + err.statusCode = 404; + throw err; + } + + await authRepository.updatePassword(request.userId, request.passwordHash); + await passwordChangeRepository.deleteRequest(token); + + await auditService.logEvent({ + type: 'PASSWORD_CHANGED', + userId: user.id, + email: user.email, + }); + + try { + await mailService.sendPasswordChangedEmail({ + name: user.name || buildDisplayNameFromEmail(user.email), + email: user.email, + changedAt: new Date(), + }); + } catch (error) { + console.error('[PASSWORD_CHANGED_EMAIL_FAILED]', error.message); + } +}; + +const verifyEmail = async (token) => { + const userId = await authRepository.verifyEmail(token); + if (!userId) { + const err = new Error('Invalid or expired verification token.'); + err.statusCode = 400; + throw err; + } + return userId; +}; + +module.exports = { register, login, refresh, logout, verifyEmail, changePassword, confirmPasswordChange }; diff --git a/backend/src/modules/auth/auth.validator.js b/backend/src/modules/auth/auth.validator.js new file mode 100644 index 0000000..825c206 --- /dev/null +++ b/backend/src/modules/auth/auth.validator.js @@ -0,0 +1,48 @@ +const Joi = require('joi'); + +const isStrongPassword = (value, helpers) => { + const rules = [ + { test: value.length >= 8, message: 'at least 8 characters' }, + { test: /[A-Z]/.test(value), message: 'one uppercase letter' }, + { test: /[a-z]/.test(value), message: 'one lowercase letter' }, + { test: /\d/.test(value), message: 'one number' }, + { test: /[^A-Za-z0-9]/.test(value), message: 'one special character' }, + ]; + + const missing = rules.filter((rule) => !rule.test).map((rule) => rule.message); + if (missing.length > 0) { + return helpers.message(`Password must include ${missing.join(', ')}.`); + } + + return value; +}; + +const registerSchema = Joi.object({ + email: Joi.string().email().required(), + password: Joi.string().max(100).required().custom(isStrongPassword), +}); + +const loginSchema = Joi.object({ + email: Joi.string().email().required(), + password: Joi.string().required(), +}); + +const refreshSchema = Joi.object({ + refreshToken: Joi.string().required(), +}); + +const changePasswordSchema = Joi.object({ + currentPassword: Joi.string().required(), + newPassword: Joi.string().max(100).required().custom(isStrongPassword), +}); + +const validate = (schema) => (req, res, next) => { + const { error } = schema.validate(req.body, { abortEarly: false }); + if (error) { + const messages = error.details.map((d) => d.message); + return res.status(400).json({ success: false, data: null, error: messages.join('; ') }); + } + next(); +}; + +module.exports = { validate, registerSchema, loginSchema, refreshSchema, changePasswordSchema }; diff --git a/backend/src/modules/auth/password-change.repository.js b/backend/src/modules/auth/password-change.repository.js new file mode 100644 index 0000000..6fdcdb8 --- /dev/null +++ b/backend/src/modules/auth/password-change.repository.js @@ -0,0 +1,43 @@ +const fs = require('fs/promises'); +const path = require('path'); + +const storageDir = path.resolve(process.cwd(), 'storage', 'auth'); +const storageFile = path.join(storageDir, 'password-change-requests.json'); + +const readRequests = async () => { + try { + const raw = await fs.readFile(storageFile, 'utf8'); + return JSON.parse(raw); + } catch (error) { + return {}; + } +}; + +const writeRequests = async (data) => { + await fs.mkdir(storageDir, { recursive: true }); + await fs.writeFile(storageFile, JSON.stringify(data, null, 2), 'utf8'); +}; + +const saveRequest = async (token, data) => { + const current = await readRequests(); + current[token] = data; + await writeRequests(current); +}; + +const findRequest = async (token) => { + const current = await readRequests(); + return current[token] || null; +}; + +const deleteRequest = async (token) => { + const current = await readRequests(); + if (!current[token]) return; + delete current[token]; + await writeRequests(current); +}; + +module.exports = { + saveRequest, + findRequest, + deleteRequest, +}; diff --git a/backend/src/modules/certificates/certificates.controller.js b/backend/src/modules/certificates/certificates.controller.js new file mode 100644 index 0000000..acfdd64 --- /dev/null +++ b/backend/src/modules/certificates/certificates.controller.js @@ -0,0 +1,36 @@ +const certificatesService = require('./certificates.service'); +const { sendSuccess } = require('../../utils/apiResponse'); + +const getCtx = (req) => ({ ip: req.ip?.replace('::ffff:', ''), userAgent: req.headers['user-agent'] }); + +const upload = async (req, res, next) => { + try { + if (!req.file) { const e = new Error('No file uploaded.'); e.statusCode = 400; throw e; } + const { password, alias } = req.body; + const cert = await certificatesService.upload(req.file, password || '', req.user.id, alias, getCtx(req)); + return sendSuccess(res, cert, 201); + } catch (err) { return next(err); } +}; + +const list = async (req, res, next) => { + try { + const certs = await certificatesService.list(req.user.id); + return sendSuccess(res, certs, 200); + } catch (err) { return next(err); } +}; + +const getOne = async (req, res, next) => { + try { + const cert = await certificatesService.getOne(Number(req.params.id), req.user.id); + return sendSuccess(res, cert, 200); + } catch (err) { return next(err); } +}; + +const remove = async (req, res, next) => { + try { + await certificatesService.remove(Number(req.params.id), req.user.id, getCtx(req)); + return sendSuccess(res, { message: 'Certificate deleted.' }, 200); + } catch (err) { return next(err); } +}; + +module.exports = { upload, list, getOne, remove }; diff --git a/backend/src/modules/certificates/certificates.repository.js b/backend/src/modules/certificates/certificates.repository.js new file mode 100644 index 0000000..985535c --- /dev/null +++ b/backend/src/modules/certificates/certificates.repository.js @@ -0,0 +1,78 @@ +const db = require('../../config/db'); +const fs = require('fs/promises'); +const path = require('path'); + +const chainMetadataDir = path.resolve(process.cwd(), 'storage', 'certificates', 'metadata'); +const chainMetadataFile = path.join(chainMetadataDir, 'chain-status.json'); + +const readChainMetadata = async () => { + try { + const raw = await fs.readFile(chainMetadataFile, 'utf8'); + return JSON.parse(raw); + } catch { + return {}; + } +}; + +const writeChainMetadata = async (data) => { + await fs.mkdir(chainMetadataDir, { recursive: true }); + await fs.writeFile(chainMetadataFile, JSON.stringify(data, null, 2), 'utf8'); +}; + +const create = async ({ userId, alias, filename, storagePath, subjectCn, issuerCn, validFrom, validTo, serialNumber }) => { + const [result] = await db.execute( + `INSERT INTO certificates (user_id, alias, filename, storage_path, subject_cn, issuer_cn, valid_from, valid_to, serial_number) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [userId, alias, filename, storagePath, subjectCn || null, issuerCn || null, validFrom || null, validTo || null, serialNumber || null] + ); + return result.insertId; +}; + +const findByUser = async (userId) => { + const [rows] = await db.execute( + 'SELECT id, user_id, alias, filename, subject_cn, issuer_cn, valid_from, valid_to, serial_number, is_active, created_at FROM certificates WHERE user_id = ? ORDER BY created_at DESC', + [userId] + ); + return rows; +}; + +const findById = async (id) => { + const [rows] = await db.execute('SELECT * FROM certificates WHERE id = ?', [id]); + return rows[0] || null; +}; + +const deactivate = async (id) => { + await db.execute('UPDATE certificates SET is_active = 0 WHERE id = ?', [id]); +}; + +const remove = async (id) => { + await db.execute('DELETE FROM certificates WHERE id = ?', [id]); +}; + +const saveChainStatus = async (certId, metadata) => { + const current = await readChainMetadata(); + current[String(certId)] = metadata; + await writeChainMetadata(current); +}; + +const getChainStatus = async (certId) => { + const current = await readChainMetadata(); + return current[String(certId)] || null; +}; + +const removeChainStatus = async (certId) => { + const current = await readChainMetadata(); + delete current[String(certId)]; + await writeChainMetadata(current); +}; + +module.exports = { + create, + findByUser, + findById, + deactivate, + remove, + saveChainStatus, + getChainStatus, + removeChainStatus, +}; diff --git a/backend/src/modules/certificates/certificates.routes.js b/backend/src/modules/certificates/certificates.routes.js new file mode 100644 index 0000000..cbbfa53 --- /dev/null +++ b/backend/src/modules/certificates/certificates.routes.js @@ -0,0 +1,15 @@ +const express = require('express'); +const { uploadCertificate } = require('../../config/multer'); +const authenticate = require('../../middlewares/authenticate'); +const certificatesController = require('./certificates.controller'); + +const router = express.Router(); + +router.use(authenticate); + +router.post('/', uploadCertificate.single('file'), certificatesController.upload); +router.get('/', certificatesController.list); +router.get('/:id', certificatesController.getOne); +router.delete('/:id', certificatesController.remove); + +module.exports = router; diff --git a/backend/src/modules/certificates/certificates.service.js b/backend/src/modules/certificates/certificates.service.js new file mode 100644 index 0000000..11f8bfd --- /dev/null +++ b/backend/src/modules/certificates/certificates.service.js @@ -0,0 +1,168 @@ +const fs = require('fs/promises'); +const forge = require('node-forge'); +const certificatesRepository = require('./certificates.repository'); +const auditService = require('../audit/audit.service'); + +const makeError = (msg, status, code) => { + const e = new Error(msg); + e.statusCode = status; + if (code) e.code = code; + return e; +}; + +const tryParsePkcs12 = (pfxBuffer, password) => { + const p12Der = forge.util.createBuffer(pfxBuffer.toString('binary')); + const p12Asn1 = forge.asn1.fromDer(p12Der); + return forge.pkcs12.pkcs12FromAsn1(p12Asn1, password || ''); +}; + +const parseCertificate = (pfxBuffer, password) => { + let p12; + try { + p12 = tryParsePkcs12(pfxBuffer, password); + } catch (err) { + const msg = err.message || ''; + if (msg.includes('MAC') || msg.includes('password') || msg.includes('integrity')) { + throw makeError('Contraseña incorrecta o requerida para este certificado.', 422, 'PASSWORD_REQUIRED'); + } + throw makeError('No se pudo leer el archivo P12. Verifica que sea un certificado válido.', 400); + } + + const certBags = p12.getBags({ bagType: forge.pki.oids.certBag }); + const certificates = certBags[forge.pki.oids.certBag] || []; + const cert = certificates[0]?.cert; + if (!cert) throw makeError('El archivo P12 no contiene un certificado válido.', 400); + + const keyBags = p12.getBags({ bagType: forge.pki.oids.pkcs8ShroudedKeyBag }); + const privateKey = keyBags[forge.pki.oids.pkcs8ShroudedKeyBag]?.[0]?.key; + if (!privateKey) throw makeError('El archivo P12 no contiene una clave privada.', 400); + + const now = new Date(); + const validTo = cert.validity.notAfter; + const validFrom = cert.validity.notBefore; + + if (now < validFrom) { + throw makeError(`El certificado aún no es válido. Válido desde: ${validFrom.toLocaleDateString('es-EC')}.`, 400); + } + if (now > validTo) { + throw makeError(`El certificado está vencido desde el ${validTo.toLocaleDateString('es-EC')}.`, 400); + } + + const getAttr = (subject, name) => subject.getField(name)?.value || null; + return { + subjectCn: getAttr(cert.subject, 'CN'), + issuerCn: getAttr(cert.issuer, 'CN'), + validFrom, + validTo, + serialNumber: cert.serialNumber, + certificateCount: certificates.length, + chainStatus: certificates.length > 1 ? 'complete' : 'leaf_only', + }; +}; + +const upload = async (file, password, userId, alias, context = {}) => { + const buffer = await fs.readFile(file.path); + const info = parseCertificate(buffer, password); // throws with code if password needed + + const certId = await certificatesRepository.create({ + userId, + alias: alias || file.originalname, + filename: file.filename, + storagePath: file.path, + ...info, + }); + + await certificatesRepository.saveChainStatus(certId, { + certificateCount: info.certificateCount, + chainStatus: info.chainStatus, + }); + + await auditService.logEvent({ + type: 'CERTIFICATE_UPLOAD', + userId, + ip: context.ip, + metadata: { + certId, + alias, + subject: info.subjectCn, + certificateCount: info.certificateCount, + chainStatus: info.chainStatus, + }, + }); + + const cert = await certificatesRepository.findById(certId); + const { storage_path, ...safeFields } = cert; + return { + ...safeFields, + certificate_count: info.certificateCount, + chain_status: info.chainStatus, + }; +}; + +const list = async (userId) => { + const certs = await certificatesRepository.findByUser(userId); + return Promise.all( + certs.map(async (cert) => { + const chain = await certificatesRepository.getChainStatus(cert.id); + return { + ...cert, + certificate_count: chain?.certificateCount || null, + chain_status: chain?.chainStatus || null, + }; + }) + ); +}; + +const getOne = async (id, userId) => { + const cert = await certificatesRepository.findById(id); + if (!cert) throw makeError('Certificate not found.', 404); + if (cert.user_id !== userId) throw makeError('Access denied.', 403); + const { storage_path, ...safeFields } = cert; + const chain = await certificatesRepository.getChainStatus(id); + return { + ...safeFields, + certificate_count: chain?.certificateCount || null, + chain_status: chain?.chainStatus || null, + }; +}; + +const remove = async (id, userId, context = {}) => { + const cert = await certificatesRepository.findById(id); + if (!cert) throw makeError('Certificate not found.', 404); + if (cert.user_id !== userId) throw makeError('Access denied.', 403); + try { await fs.unlink(cert.storage_path); } catch { /* already gone */ } + await certificatesRepository.remove(id); + await certificatesRepository.removeChainStatus(id); + await auditService.logEvent({ type: 'CERTIFICATE_DELETE', userId, ip: context.ip, metadata: { certId: id } }); +}; + +const loadForSigning = async (certId, userId, password) => { + const cert = await certificatesRepository.findById(certId); + if (!cert) throw makeError('Certificate not found.', 404); + if (cert.user_id !== userId) throw makeError('Access denied.', 403); + + if (cert.valid_to && new Date() > new Date(cert.valid_to)) { + throw makeError(`El certificado "${cert.alias}" está vencido desde el ${new Date(cert.valid_to).toLocaleDateString('es-EC')}.`, 400); + } + + const buffer = await fs.readFile(cert.storage_path); + let p12; + try { + p12 = tryParsePkcs12(buffer, password); + } catch { + throw makeError('Contraseña del certificado incorrecta o requerida.', 422, 'CERT_PASSWORD_REQUIRED'); + } + + const keyBags = p12.getBags({ bagType: forge.pki.oids.pkcs8ShroudedKeyBag }); + const certBags = p12.getBags({ bagType: forge.pki.oids.certBag }); + const privateKey = keyBags[forge.pki.oids.pkcs8ShroudedKeyBag]?.[0]?.key; + const certificate = certBags[forge.pki.oids.certBag]?.[0]?.cert; + + if (!privateKey || !certificate) { + throw makeError('No se pudo extraer la clave del certificado. Verifica la contraseña.', 400, 'CERT_PASSWORD_REQUIRED'); + } + + return { privateKey, certificate }; +}; + +module.exports = { upload, list, getOne, remove, loadForSigning }; diff --git a/backend/src/modules/documents/documents.controller.js b/backend/src/modules/documents/documents.controller.js new file mode 100644 index 0000000..4181c52 --- /dev/null +++ b/backend/src/modules/documents/documents.controller.js @@ -0,0 +1,44 @@ +const documentsService = require('./documents.service'); +const { sendSuccess } = require('../../utils/apiResponse'); + +const getCtx = (req) => ({ ip: req.ip?.replace('::ffff:', ''), userAgent: req.headers['user-agent'] }); + +const upload = async (req, res, next) => { + try { + if (!req.file) { + const err = new Error('No file uploaded.'); err.statusCode = 400; throw err; + } + const doc = await documentsService.upload(req.file, req.user.id, getCtx(req)); + return sendSuccess(res, doc, 201); + } catch (err) { return next(err); } +}; + +const list = async (req, res, next) => { + try { + const result = await documentsService.list(req.user.id, req.query); + return sendSuccess(res, result, 200); + } catch (err) { return next(err); } +}; + +const getOne = async (req, res, next) => { + try { + const doc = await documentsService.getOne(Number(req.params.id), req.user.id); + return sendSuccess(res, doc, 200); + } catch (err) { return next(err); } +}; + +const download = async (req, res, next) => { + try { + const doc = await documentsService.getOne(Number(req.params.id), req.user.id); + res.download(doc.storage_path, doc.filename); + } catch (err) { return next(err); } +}; + +const remove = async (req, res, next) => { + try { + await documentsService.remove(Number(req.params.id), req.user.id, getCtx(req)); + return sendSuccess(res, { message: 'Document deleted.' }, 200); + } catch (err) { return next(err); } +}; + +module.exports = { upload, list, getOne, download, remove }; diff --git a/backend/src/modules/documents/documents.repository.js b/backend/src/modules/documents/documents.repository.js new file mode 100644 index 0000000..b85bf82 --- /dev/null +++ b/backend/src/modules/documents/documents.repository.js @@ -0,0 +1,40 @@ +const db = require('../../config/db'); + +const create = async ({ userId, title, filename, storagePath, fileSize, hashSha256 }) => { + const [result] = await db.execute( + 'INSERT INTO documents (user_id, title, filename, storage_path, file_size, hash_sha256) VALUES (?, ?, ?, ?, ?, ?)', + [userId, title, filename, storagePath, fileSize, hashSha256 || null] + ); + return result.insertId; +}; + +const findById = async (id) => { + const [rows] = await db.execute( + 'SELECT d.*, u.name as owner_name, u.email as owner_email FROM documents d JOIN users u ON d.user_id = u.id WHERE d.id = ?', + [id] + ); + return rows[0] || null; +}; + +const findByUser = async (userId, { limit = 50, offset = 0 } = {}) => { + const [rows] = await db.execute( + 'SELECT * FROM documents WHERE user_id = ? ORDER BY created_at DESC LIMIT ? OFFSET ?', + [userId, limit, offset] + ); + return rows; +}; + +const countByUser = async (userId) => { + const [rows] = await db.execute('SELECT COUNT(*) as total FROM documents WHERE user_id = ?', [userId]); + return rows[0].total; +}; + +const updateStatus = async (id, status) => { + await db.execute('UPDATE documents SET status = ? WHERE id = ?', [status, id]); +}; + +const remove = async (id) => { + await db.execute('DELETE FROM documents WHERE id = ?', [id]); +}; + +module.exports = { create, findById, findByUser, countByUser, updateStatus, remove }; diff --git a/backend/src/modules/documents/documents.routes.js b/backend/src/modules/documents/documents.routes.js new file mode 100644 index 0000000..d200f9d --- /dev/null +++ b/backend/src/modules/documents/documents.routes.js @@ -0,0 +1,16 @@ +const express = require('express'); +const { uploadDocument } = require('../../config/multer'); +const authenticate = require('../../middlewares/authenticate'); +const documentsController = require('./documents.controller'); + +const router = express.Router(); + +router.use(authenticate); + +router.post('/', uploadDocument.single('file'), documentsController.upload); +router.get('/', documentsController.list); +router.get('/:id', documentsController.getOne); +router.get('/:id/download', documentsController.download); +router.delete('/:id', documentsController.remove); + +module.exports = router; diff --git a/backend/src/modules/documents/documents.service.js b/backend/src/modules/documents/documents.service.js new file mode 100644 index 0000000..c04696d --- /dev/null +++ b/backend/src/modules/documents/documents.service.js @@ -0,0 +1,59 @@ +const fs = require('fs/promises'); +const crypto = require('crypto'); +const documentsRepository = require('./documents.repository'); +const auditService = require('../audit/audit.service'); + +const upload = async (file, userId, context = {}) => { + const buffer = await fs.readFile(file.path); + const hash = crypto.createHash('sha256').update(buffer).digest('hex'); + + const title = file.originalname.replace(/\.pdf$/i, ''); + const docId = await documentsRepository.create({ + userId, + title, + filename: file.filename, + storagePath: file.path, + fileSize: file.size, + hashSha256: hash, + }); + + await auditService.logEvent({ + type: 'DOCUMENT_UPLOAD', + userId, + ip: context.ip, + userAgent: context.userAgent, + metadata: { docId, filename: file.originalname, size: file.size }, + }); + + return documentsRepository.findById(docId); +}; + +const list = async (userId, query = {}) => { + const limit = Math.min(Number(query.limit) || 20, 100); + const offset = Number(query.offset) || 0; + const [rows, total] = await Promise.all([ + documentsRepository.findByUser(userId, { limit, offset }), + documentsRepository.countByUser(userId), + ]); + return { rows, total, limit, offset }; +}; + +const getOne = async (id, userId) => { + const doc = await documentsRepository.findById(id); + if (!doc) { + const err = new Error('Document not found.'); err.statusCode = 404; throw err; + } + if (doc.user_id !== userId) { + const err = new Error('Access denied.'); err.statusCode = 403; throw err; + } + return doc; +}; + +const remove = async (id, userId, context = {}) => { + const doc = await getOne(id, userId); + try { await fs.unlink(doc.storage_path); } catch { /* already deleted */ } + await documentsRepository.remove(id); + await auditService.logEvent({ type: 'DOCUMENT_DELETE', userId, ip: context.ip, metadata: { docId: id } }); +}; + +module.exports = { upload, list, getOne, remove }; diff --git a/backend/src/modules/health/health.controller.js b/backend/src/modules/health/health.controller.js new file mode 100644 index 0000000..a9931e5 --- /dev/null +++ b/backend/src/modules/health/health.controller.js @@ -0,0 +1,15 @@ +const healthService = require('./health.service'); +const { sendSuccess } = require('../../utils/apiResponse'); + +const getHealth = async (req, res, next) => { + try { + const result = await healthService.getHealth(); + return sendSuccess(res, result); + } catch (error) { + return next(error); + } +}; + +module.exports = { + getHealth, +}; diff --git a/backend/src/modules/health/health.repository.js b/backend/src/modules/health/health.repository.js new file mode 100644 index 0000000..71ac068 --- /dev/null +++ b/backend/src/modules/health/health.repository.js @@ -0,0 +1,9 @@ +const getStatus = async () => { + return { + status: 'ok', + }; +}; + +module.exports = { + getStatus, +}; diff --git a/backend/src/modules/health/health.routes.js b/backend/src/modules/health/health.routes.js new file mode 100644 index 0000000..2c504b6 --- /dev/null +++ b/backend/src/modules/health/health.routes.js @@ -0,0 +1,8 @@ +const express = require('express'); +const healthController = require('./health.controller'); + +const router = express.Router(); + +router.get('/', healthController.getHealth); + +module.exports = router; diff --git a/backend/src/modules/health/health.service.js b/backend/src/modules/health/health.service.js new file mode 100644 index 0000000..d5fcd3d --- /dev/null +++ b/backend/src/modules/health/health.service.js @@ -0,0 +1,14 @@ +const healthRepository = require('./health.repository'); + +const getHealth = async () => { + const baseStatus = await healthRepository.getStatus(); + + return { + ...baseStatus, + timestamp: new Date().toISOString(), + }; +}; + +module.exports = { + getHealth, +}; diff --git a/backend/src/modules/mail/mail.repository.js b/backend/src/modules/mail/mail.repository.js new file mode 100644 index 0000000..b87d119 --- /dev/null +++ b/backend/src/modules/mail/mail.repository.js @@ -0,0 +1,49 @@ +const nodemailer = require('nodemailer'); +const env = require('../../config/env'); + +let transporter; + +const getTransporter = () => { + if (transporter) return transporter; + + if (env.mail.user && env.mail.pass) { + transporter = nodemailer.createTransport({ + host: env.mail.host, + port: env.mail.port, + secure: env.mail.secure, + auth: { + user: env.mail.user, + pass: env.mail.pass, + }, + tls: { + rejectUnauthorized: env.mail.rejectUnauthorized, + }, + }); + return transporter; + } + + transporter = nodemailer.createTransport({ + streamTransport: true, + newline: 'unix', + buffer: true, + }); + + return transporter; +}; + +const sendMail = async ({ to, subject, html, text }) => { + const activeTransporter = getTransporter(); + const info = await activeTransporter.sendMail({ + from: env.mail.from, + to, + subject, + html, + text, + }); + + return info; +}; + +module.exports = { + sendMail, +}; diff --git a/backend/src/modules/mail/mail.service.js b/backend/src/modules/mail/mail.service.js new file mode 100644 index 0000000..247d9a0 --- /dev/null +++ b/backend/src/modules/mail/mail.service.js @@ -0,0 +1,128 @@ +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 text = [ + `Hola ${name},`, + '', + 'Confirma tu cuenta de LinkedSign usando este enlace:', + verificationUrl, + '', + 'Si no solicitaste esta cuenta, puedes ignorar este mensaje.', + ].join('\n'); + const html = ` +
+

Confirma tu cuenta de LinkedSign

+

Hola ${name},

+

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

+

+ + Confirmar cuenta + +

+

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

+

${verificationUrl}

+

Si no solicitaste esta cuenta, puedes ignorar este mensaje.

+
+ `; + + const info = await mailRepository.sendMail({ + to: email, + subject, + html, + text, + }); + + if (!env.mail.user || !env.mail.pass) { + console.log('[mail] SMTP no configurado. Enlace de verificación:'); + console.log(verificationUrl); + } + + return { + verificationUrl, + messageId: info.messageId || null, + }; +}; + +const sendPasswordChangedEmail = async ({ name, email, changedAt }) => { + const subject = 'Tu contraseña de LinkedSign fue actualizada'; + const formattedDate = changedAt.toLocaleString('es-EC'); + const text = [ + `Hola ${name},`, + '', + 'Te confirmamos que la contraseña de tu cuenta de LinkedSign fue actualizada.', + `Fecha: ${formattedDate}`, + '', + 'Si no realizaste este cambio, contacta soporte inmediatamente.', + ].join('\n'); + const html = ` +
+

Contraseña actualizada

+

Hola ${name},

+

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

+

Fecha: ${formattedDate}

+

Si no realizaste este cambio, contacta soporte inmediatamente.

+
+ `; + + const info = await mailRepository.sendMail({ + to: email, + subject, + html, + text, + }); + + return { + messageId: info.messageId || null, + }; +}; + +const sendPasswordChangeConfirmationEmail = async ({ name, email, confirmationToken }) => { + const confirmationUrl = `${env.frontendUrl.replace(/\/$/, '')}/auth/confirm-password-change/${confirmationToken}`; + const subject = 'Confirma el cambio de contraseña de LinkedSign'; + const text = [ + `Hola ${name},`, + '', + 'Recibimos una solicitud para cambiar la contraseña de tu cuenta.', + 'Para aplicar el cambio, confirma la solicitud usando este enlace:', + confirmationUrl, + '', + 'Si no realizaste esta solicitud, ignora este mensaje.', + ].join('\n'); + const html = ` +
+

Confirma el cambio de contraseña

+

Hola ${name},

+

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

+

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

+

+ + Confirmar cambio de contraseña + +

+

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

+

${confirmationUrl}

+

Si no realizaste esta solicitud, ignora este mensaje.

+
+ `; + + const info = await mailRepository.sendMail({ + to: email, + subject, + html, + text, + }); + + return { + confirmationUrl, + messageId: info.messageId || null, + }; +}; + +module.exports = { + sendVerificationEmail, + sendPasswordChangedEmail, + sendPasswordChangeConfirmationEmail, +}; diff --git a/backend/src/modules/signatures/signatures.controller.js b/backend/src/modules/signatures/signatures.controller.js new file mode 100644 index 0000000..a34442a --- /dev/null +++ b/backend/src/modules/signatures/signatures.controller.js @@ -0,0 +1,67 @@ +const signaturesService = require('./signatures.service'); +const signaturesRepository = require('./signatures.repository'); +const { sendSuccess } = require('../../utils/apiResponse'); + +const getCtx = (req) => ({ + ip: req.ip?.replace('::ffff:', ''), + userAgent: req.headers['user-agent'], + userName: req.user?.name || req.user?.email, + userEmail: req.user?.email || null, + frontendUrl: req.headers.origin || null, +}); + +const sign = async (req, res, next) => { + try { + const { documentId, certificateId, certPassword, reason, location, page, rect, signatureStyle, qrPosition, qrBackground, qrBorder, signatureImageBase64 } = 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 }, + req.user.id, + getCtx(req) + ); + return sendSuccess(res, result, 200); + } catch (err) { return next(err); } +}; + +const getSignatures = async (req, res, next) => { + try { + const sigs = await signaturesService.getSignatures(Number(req.params.documentId), req.user.id); + return sendSuccess(res, sigs, 200); + } catch (err) { return next(err); } +}; + +const listSignedDocuments = async (req, res, next) => { + try { + const rows = await signaturesService.listSignedDocuments(req.user.id); + return sendSuccess(res, rows, 200); + } catch (err) { return next(err); } +}; + +const verify = async (req, res, next) => { + try { + const result = await signaturesService.verifySignature(req.params.code); + return sendSuccess(res, result, 200); + } catch (err) { return next(err); } +}; + +const download = async (req, res, next) => { + try { + const sig = await signaturesRepository.findById(Number(req.params.id)); + if (!sig) { const e = new Error('Signature not found.'); e.statusCode = 404; throw e; } + if (sig.user_id !== req.user.id) { const e = new Error('Access denied.'); e.statusCode = 403; throw e; } + res.download(sig.storage_path, sig.signed_filename); + } catch (err) { return next(err); } +}; + +const remove = async (req, res, next) => { + try { + await signaturesService.removeSignedDocument(Number(req.params.id), req.user.id, getCtx(req)); + return sendSuccess(res, { message: 'Signed document deleted.' }, 200); + } catch (err) { return next(err); } +}; + +module.exports = { sign, getSignatures, listSignedDocuments, verify, download, remove }; diff --git a/backend/src/modules/signatures/signatures.repository.js b/backend/src/modules/signatures/signatures.repository.js new file mode 100644 index 0000000..383c561 --- /dev/null +++ b/backend/src/modules/signatures/signatures.repository.js @@ -0,0 +1,114 @@ +const db = require('../../config/db'); +const fs = require('fs/promises'); +const path = require('path'); + +const verificationDir = path.resolve(process.cwd(), 'storage', 'signatures'); +const verificationFile = path.join(verificationDir, 'verification-codes.json'); + +const readVerificationMap = async () => { + try { + const raw = await fs.readFile(verificationFile, 'utf8'); + return JSON.parse(raw); + } catch { + return {}; + } +}; + +const writeVerificationMap = async (data) => { + await fs.mkdir(verificationDir, { recursive: true }); + await fs.writeFile(verificationFile, JSON.stringify(data, null, 2), 'utf8'); +}; + +const create = async ({ documentId, userId, certificateId, signedFilename, storagePath, reason, location }) => { + const [result] = await db.execute( + `INSERT INTO signatures (document_id, user_id, certificate_id, signed_filename, storage_path, reason, location) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + [documentId, userId, certificateId, signedFilename, storagePath, reason || null, location || null] + ); + return result.insertId; +}; + +const findByDocument = async (documentId) => { + const [rows] = await db.execute( + `SELECT s.*, u.name as signer_name, u.email as signer_email, c.alias as cert_alias + FROM signatures s + JOIN users u ON s.user_id = u.id + JOIN certificates c ON s.certificate_id = c.id + WHERE s.document_id = ? + ORDER BY s.signed_at ASC`, + [documentId] + ); + return rows; +}; + +const findById = async (id) => { + const [rows] = await db.execute('SELECT * FROM signatures WHERE id = ?', [id]); + return rows[0] || null; +}; + +const findByUser = async (userId) => { + const [rows] = await db.execute( + `SELECT + s.id, + s.document_id, + s.user_id, + s.certificate_id, + s.signed_filename, + s.storage_path, + s.reason, + s.location, + s.signed_at, + d.title AS document_title, + d.filename AS original_filename, + c.alias AS certificate_alias + FROM signatures s + JOIN documents d ON s.document_id = d.id + JOIN certificates c ON s.certificate_id = c.id + WHERE s.user_id = ? + ORDER BY s.signed_at DESC, s.id DESC`, + [userId] + ); + return rows; +}; + +const saveVerificationRecord = async (code, data) => { + const current = await readVerificationMap(); + current[code] = data; + await writeVerificationMap(current); +}; + +const findVerificationRecord = async (code) => { + const current = await readVerificationMap(); + return current[code] || null; +}; + +const deleteVerificationRecordBySignatureId = async (signatureId) => { + const current = await readVerificationMap(); + let changed = false; + + Object.keys(current).forEach((code) => { + if (current[code]?.signatureId === signatureId) { + delete current[code]; + changed = true; + } + }); + + if (changed) { + await writeVerificationMap(current); + } +}; + +const remove = async (id) => { + await db.execute('DELETE FROM signatures WHERE id = ?', [id]); +}; + +module.exports = { + create, + findByDocument, + findById, + findByUser, + saveVerificationRecord, + findVerificationRecord, + deleteVerificationRecordBySignatureId, + remove, +}; diff --git a/backend/src/modules/signatures/signatures.routes.js b/backend/src/modules/signatures/signatures.routes.js new file mode 100644 index 0000000..6601f8e --- /dev/null +++ b/backend/src/modules/signatures/signatures.routes.js @@ -0,0 +1,17 @@ +const express = require('express'); +const authenticate = require('../../middlewares/authenticate'); +const signaturesController = require('./signatures.controller'); + +const router = express.Router(); + +router.get('/verify/:code', signaturesController.verify); + +router.use(authenticate); + +router.post('/sign', signaturesController.sign); +router.get('/mine', signaturesController.listSignedDocuments); +router.get('/document/:documentId', signaturesController.getSignatures); +router.get('/:id/download', signaturesController.download); +router.delete('/:id', signaturesController.remove); + +module.exports = router; diff --git a/backend/src/modules/signatures/signatures.service.js b/backend/src/modules/signatures/signatures.service.js new file mode 100644 index 0000000..7038177 --- /dev/null +++ b/backend/src/modules/signatures/signatures.service.js @@ -0,0 +1,200 @@ +const fs = require('fs/promises'); +const crypto = require('crypto'); +const { v4: uuidv4 } = require('uuid'); +const signaturesRepository = require('./signatures.repository'); +const documentsRepository = require('../documents/documents.repository'); +const certificatesRepository = require('../certificates/certificates.repository'); +const certificatesService = require('../certificates/certificates.service'); +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 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; } + if (doc.status === 'signed') { const e = new Error('Document already signed.'); e.statusCode = 409; throw e; } + + const cert = await certificatesRepository.findById(certificateId); + if (!cert) { const e = new Error('Certificate not found.'); e.statusCode = 404; throw e; } + if (cert.user_id !== userId) { const e = new Error('Access denied.'); e.statusCode = 403; throw e; } + + if (cert.valid_to && new Date() > new Date(cert.valid_to)) { + const e = new Error(`El certificado "${cert.alias}" está vencido.`); e.statusCode = 400; throw e; + } + + // Load p12 buffer — throws CERT_PASSWORD_REQUIRED if wrong/missing password + // We need the raw buffer for signing; re-use loadForSigning just to validate password + await certificatesService.loadForSigning(certificateId, userId, certPassword); + const p12Buffer = await fs.readFile(cert.storage_path); + + const signerName = cert.subject_cn || context.userName || 'Firmante'; + const verificationCode = uuidv4(); + const frontendBaseUrl = (context.frontendUrl || env.frontendUrl || '').replace(/\/$/, ''); + const verificationUrl = `${frontendBaseUrl}/verify/${verificationCode}`; + + let signedFilename, signedPath; + try { + ({ signedFilename, signedPath } = await signPdf({ + pdfPath: doc.storage_path, + p12Buffer, + p12Password: certPassword || '', + signerName, + reason: reason || 'Firmado digitalmente', + location: location || '', + page: page || 0, + rect: rect || null, + signatureStyle: signatureStyle || 'classic', + qrPosition: qrPosition || 'left', + qrBackground: qrBackground || 'transparent', + qrBorder: qrBorder || 'none', + signatureImageBase64: signatureImageBase64 || null, + verificationUrl, + })); + } catch (signErr) { + const msg = signErr.message || ''; + // Re-map known signing errors + if (msg.includes('MAC') || msg.includes('password') || msg.includes('passphrase')) { + const e = new Error('Contraseña del certificado incorrecta.'); + e.statusCode = 422; e.code = 'CERT_PASSWORD_REQUIRED'; throw e; + } + if (msg.includes('encrypt') || msg.includes('decrypt') || msg.includes('permission')) { + const e = new Error('El PDF está protegido y no puede firmarse.'); e.statusCode = 400; throw e; + } + // Unknown error — re-throw with detail + const e = new Error(`Error al firmar el documento: ${msg}`); e.statusCode = 500; throw e; + } + + const sigId = await signaturesRepository.create({ + documentId, + userId, + certificateId, + signedFilename, + storagePath: signedPath, + reason, + location, + }); + + const signedBuffer = await fs.readFile(signedPath); + const signedHashSha256 = crypto.createHash('sha256').update(signedBuffer).digest('hex'); + + await signaturesRepository.saveVerificationRecord(verificationCode, { + signatureId: sigId, + documentId, + certificateId, + userId, + verificationCode, + verificationUrl, + signerName, + signerEmail: context.userEmail || null, + certificateSubjectCn: cert.subject_cn || null, + certificateIssuerCn: cert.issuer_cn || null, + signedFilename, + signedPath, + signedHashSha256, + reason: reason || 'Firmado digitalmente', + location: location || '', + signedAt: new Date().toISOString(), + documentTitle: doc.title, + }); + + await documentsRepository.updateStatus(documentId, 'signed'); + + await auditService.logEvent({ + type: 'DOCUMENT_SIGNED', + userId, + ip: context.ip, + metadata: { documentId, certificateId, sigId }, + }); + + return { sigId, signedFilename, documentId, verificationCode, verificationUrl }; +}; + +const getSignatures = async (documentId, userId) => { + 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; } + return signaturesRepository.findByDocument(documentId); +}; + +const listSignedDocuments = async (userId) => { + return signaturesRepository.findByUser(userId); +}; + +const verifySignature = async (verificationCode) => { + const record = await signaturesRepository.findVerificationRecord(verificationCode); + if (!record) { + const error = new Error('Verification record not found.'); + error.statusCode = 404; + throw error; + } + + const signature = await signaturesRepository.findById(record.signatureId); + if (!signature) { + const error = new Error('Signature not found.'); + error.statusCode = 404; + throw error; + } + + const signedBuffer = await fs.readFile(record.signedPath); + const currentHashSha256 = crypto.createHash('sha256').update(signedBuffer).digest('hex'); + const isValid = currentHashSha256 === record.signedHashSha256; + + return { + isValid, + verificationCode: record.verificationCode, + verificationUrl: record.verificationUrl, + signerName: record.signerName, + signerEmail: record.signerEmail, + certificateSubjectCn: record.certificateSubjectCn, + certificateIssuerCn: record.certificateIssuerCn, + signedFilename: record.signedFilename, + documentTitle: record.documentTitle, + reason: record.reason, + location: record.location, + signedAt: record.signedAt, + integrity: { + expectedHashSha256: record.signedHashSha256, + currentHashSha256, + }, + }; +}; + +const removeSignedDocument = async (signatureId, userId, context = {}) => { + const signature = await signaturesRepository.findById(signatureId); + if (!signature) { + const error = new Error('Signature not found.'); + error.statusCode = 404; + throw error; + } + + if (signature.user_id !== userId) { + const error = new Error('Access denied.'); + error.statusCode = 403; + throw error; + } + + const document = await documentsRepository.findById(signature.document_id); + + try { await fs.unlink(signature.storage_path); } catch {} + await signaturesRepository.deleteVerificationRecordBySignatureId(signatureId); + await signaturesRepository.remove(signatureId); + + if (document && document.user_id === userId) { + try { await fs.unlink(document.storage_path); } catch {} + await documentsRepository.remove(document.id); + } + + await auditService.logEvent({ + type: 'SIGNED_DOCUMENT_DELETE', + userId, + ip: context.ip, + userAgent: context.userAgent, + metadata: { + signatureId, + documentId: signature.document_id, + }, + }); +}; + +module.exports = { sign, getSignatures, listSignedDocuments, verifySignature, removeSignedDocument }; diff --git a/backend/src/modules/signing/signing.service.js b/backend/src/modules/signing/signing.service.js new file mode 100644 index 0000000..b071ce1 --- /dev/null +++ b/backend/src/modules/signing/signing.service.js @@ -0,0 +1,313 @@ +const fs = require('fs/promises'); +const path = require('path'); +const QRCode = require('qrcode'); +const { PDFDocument, rgb, StandardFonts } = require('pdf-lib'); +const { pdflibAddPlaceholder } = require('@signpdf/placeholder-pdf-lib'); +const { SignPdf } = require('@signpdf/signpdf'); +const { P12Signer } = require('@signpdf/signer-p12'); +const { getSignedDocumentPath } = require('../../utils/storagePath'); + +// ── Color helpers ────────────────────────────────────────────────────────────── +const C = { + primary: rgb(0.31, 0.27, 0.90), + dark: rgb(0.10, 0.10, 0.30), + gray: rgb(0.35, 0.35, 0.45), + lightGray: rgb(0.60, 0.60, 0.65), + bgBlue: rgb(0.95, 0.97, 1.00), + bgDark: rgb(0.12, 0.14, 0.25), + bgGray: rgb(0.96, 0.96, 0.97), + white: rgb(1.00, 1.00, 1.00), + accent: rgb(0.20, 0.60, 0.85), + green: rgb(0.10, 0.55, 0.30), + bgGreen: rgb(0.93, 0.98, 0.94), +}; + +// ── Shared text truncation ───────────────────────────────────────────────────── +function truncate(text, font, size, maxWidth) { + if (!text) return ''; + const ellipsis = '...'; + if (font.widthOfTextAtSize(text, size) <= maxWidth) return text; + let t = text; + while (t.length > 0 && font.widthOfTextAtSize(t + ellipsis, size) > maxWidth) t = t.slice(0, -1); + return t + ellipsis; +} + +function getSignatureWidth(style, signerName = '') { + if (style !== 'qr') return 200; + const normalizedName = String(signerName).trim(); + const calculatedWidth = 78 + (normalizedName.length * 4.9); + return Math.max(170, Math.min(270, Math.round(calculatedWidth))); +} + +// ── Style: CLASSIC (blue bar + DS badge) ────────────────────────────────────── +async function drawStyleClassic(pdfDoc, page, sx, sy, sw, sh, { signerName, reason, dateStr, timeStr, location }) { + const f1 = await pdfDoc.embedFont(StandardFonts.HelveticaBold); + const f2 = await pdfDoc.embedFont(StandardFonts.Helvetica); + + page.drawRectangle({ x: sx, y: sy, width: sw, height: sh, color: C.bgBlue, borderColor: C.primary, borderWidth: 1 }); + page.drawRectangle({ x: sx, y: sy, width: 4, height: sh, color: C.primary }); + + const nameSize = signerName.length > 24 ? 7 : 8.5; + page.drawText('DS', { x: sx + 7, y: sy + sh - 18, size: 9, font: f1, color: C.primary }); + page.drawText(truncate(signerName, f1, nameSize, sw - 30), { x: sx + 22, y: sy + sh - 17, size: nameSize, font: f1, color: C.dark }); + if (reason) page.drawText(`Razon: ${truncate(reason, f2, 7, sw - 60)}`, { x: sx + 8, y: sy + sh - 30, size: 7, font: f2, color: C.gray }); + page.drawText(`Fecha: ${dateStr} ${timeStr}`, { x: sx + 8, y: sy + sh - 41, size: 7, font: f2, color: C.gray }); + if (location) page.drawText(`Lugar: ${truncate(location, f2, 7, sw - 60)}`, { x: sx + 8, y: sy + sh - 52, size: 7, font: f2, color: C.gray }); + page.drawText('Firma digital', { x: sx + sw - 54, y: sy + 4, size: 6, font: f2, color: C.lightGray }); +} + +// ── Style: QR CODE ───────────────────────────────────────────────────────────── +async function drawStyleQR(pdfDoc, page, sx, sy, sw, sh, { signerName, reason, dateStr, timeStr, location, verificationUrl, qrPosition, qrBackground, qrBorder }) { + const f1 = await pdfDoc.embedFont(StandardFonts.Helvetica); + const f2 = await pdfDoc.embedFont(StandardFonts.Helvetica); + const useSoftBackground = qrBackground === 'soft'; + const useBorder = qrBorder === 'line'; + + const qrContent = verificationUrl || [ + 'FIRMA DIGITAL VERIFICADA', + `Firmante: ${signerName}`, + reason ? `Razon: ${reason}` : '', + `Fecha: ${dateStr} ${timeStr}`, + location ? `Lugar: ${location}` : '', + ].filter(Boolean).join('\n'); + + // Generate QR as PNG buffer + const qrPng = await QRCode.toBuffer(qrContent, { + type: 'png', + width: 100, + margin: 1, + color: { dark: '#1f1b6e', light: useSoftBackground ? '#f2f4ff' : '#0000' }, + }); + + const qrImage = await pdfDoc.embedPng(qrPng); + const qrSize = Math.min(sh - 10, 44); + const qrOnRight = qrPosition === 'right'; + const textPadding = 6; + const textX = qrOnRight ? sx + textPadding : sx + qrSize + 6; + const textW = sw - qrSize - 10; + + // Background + if (useSoftBackground) { + page.drawRectangle({ x: sx, y: sy, width: sw, height: sh, color: C.bgBlue }); + } + if (useBorder) { + page.drawRectangle({ x: sx, y: sy, width: sw, height: sh, borderColor: C.primary, borderWidth: 1 }); + } + const qrX = qrOnRight ? sx + sw - qrSize - 2 : sx + 2; + if (useSoftBackground) { + page.drawRectangle({ x: qrOnRight ? sx + sw - qrSize - 4 : sx, y: sy, width: qrSize + 4, height: sh, color: C.bgBlue }); + } + page.drawImage(qrImage, { x: qrX, y: sy + 4, width: qrSize, height: qrSize }); + + const dividerX = qrOnRight ? sx + sw - qrSize - 4 : textX - 2; + page.drawLine({ start: { x: dividerX, y: sy + 4 }, end: { x: dividerX, y: sy + sh - 4 }, thickness: 0.5, color: C.primary, opacity: 0.4 }); + + // Text on right + const nameSize = signerName.length > 26 ? 6.8 : 7.6; + page.drawText(truncate(signerName, f1, nameSize, textW), { x: textX, y: sy + sh - 13, size: nameSize, font: f1, color: C.dark }); + if (reason) page.drawText(truncate(reason, f2, 6.2, textW), { x: textX, y: sy + sh - 23, size: 6.2, font: f2, color: C.gray }); + page.drawText(`${dateStr}`, { x: textX, y: sy + sh - 33, size: 6.2, font: f2, color: C.gray }); + page.drawText(`${timeStr}`, { x: textX, y: sy + sh - 43, size: 6.2, font: f2, color: C.gray }); + if (location) page.drawText(truncate(location, f2, 5.6, textW), { x: textX, y: sy + sh - 52, size: 5.6, font: f2, color: C.lightGray }); + page.drawText('Escanee el QR para verificar', { x: sx + 2, y: sy + 2, size: 5, font: f2, color: C.lightGray }); +} + +// ── Style: PROFESSIONAL (dark header + fields) ──────────────────────────────── +async function drawStyleProfessional(pdfDoc, page, sx, sy, sw, sh, { signerName, reason, dateStr, timeStr, location }) { + const f1 = await pdfDoc.embedFont(StandardFonts.HelveticaBold); + const f2 = await pdfDoc.embedFont(StandardFonts.Helvetica); + + const headerH = 16; + + // Background + page.drawRectangle({ x: sx, y: sy, width: sw, height: sh, color: C.bgGray, borderColor: rgb(0.70, 0.72, 0.78), borderWidth: 0.75 }); + // Dark header bar + page.drawRectangle({ x: sx, y: sy + sh - headerH, width: sw, height: headerH, color: C.bgDark }); + + page.drawText('FIRMA DIGITAL', { x: sx + 6, y: sy + sh - headerH + 4, size: 7.5, font: f1, color: C.white }); + page.drawText('Firmado electronicamente', { x: sx + sw - 88, y: sy + sh - headerH + 4, size: 5.5, font: f2, color: rgb(0.7, 0.7, 0.85) }); + + // Separator line below header + page.drawLine({ start: { x: sx, y: sy + sh - headerH - 1 }, end: { x: sx + sw, y: sy + sh - headerH - 1 }, thickness: 0.5, color: C.primary, opacity: 0.3 }); + + // Signer name (prominent) + const nameSize = signerName.length > 28 ? 7.5 : 9; + page.drawText(truncate(signerName, f1, nameSize, sw - 10), { x: sx + 6, y: sy + sh - headerH - 14, size: nameSize, font: f1, color: C.dark }); + + // Separator line + const lineY = sy + sh - headerH - 20; + page.drawLine({ start: { x: sx + 4, y: lineY }, end: { x: sx + sw - 4, y: lineY }, thickness: 0.4, color: C.lightGray, opacity: 0.6 }); + + // Fields in two columns + const col1X = sx + 6; + const col2X = sx + sw / 2 + 2; + const fieldW = sw / 2 - 10; + + page.drawText('Razon:', { x: col1X, y: lineY - 11, size: 5.5, font: f1, color: C.gray }); + page.drawText(truncate(reason || 'Firmado digitalmente', f2, 6, fieldW), { x: col1X, y: lineY - 20, size: 6, font: f2, color: C.dark }); + + page.drawText('Fecha:', { x: col2X, y: lineY - 11, size: 5.5, font: f1, color: C.gray }); + page.drawText(`${dateStr}`, { x: col2X, y: lineY - 20, size: 6, font: f2, color: C.dark }); + page.drawText(`${timeStr}`, { x: col2X, y: lineY - 28, size: 6, font: f2, color: C.dark }); + + if (location) { + page.drawText('Lugar:', { x: col1X, y: lineY - 30, size: 5.5, font: f1, color: C.gray }); + page.drawText(truncate(location, f2, 6, fieldW), { x: col1X, y: lineY - 39, size: 6, font: f2, color: C.dark }); + } +} + +// ── Style: IMAGE (custom handwritten image + info) ──────────────────────────── +async function drawStyleImage(pdfDoc, page, sx, sy, sw, sh, { signerName, reason, dateStr, timeStr, location, signatureImageBase64 }) { + const f1 = await pdfDoc.embedFont(StandardFonts.HelveticaBold); + const f2 = await pdfDoc.embedFont(StandardFonts.Helvetica); + + // Background + page.drawRectangle({ x: sx, y: sy, width: sw, height: sh, color: C.white, borderColor: rgb(0.75, 0.75, 0.80), borderWidth: 0.75 }); + page.drawRectangle({ x: sx, y: sy, width: 4, height: sh, color: C.green }); + + const imgAreaW = sw * 0.45; + const imgAreaH = sh - 10; + + if (signatureImageBase64) { + try { + // Strip data URL prefix if present + const base64Data = signatureImageBase64.replace(/^data:image\/\w+;base64,/, ''); + const imgBuf = Buffer.from(base64Data, 'base64'); + + let embeddedImg; + try { + embeddedImg = await pdfDoc.embedPng(imgBuf); + } catch { + embeddedImg = await pdfDoc.embedJpg(imgBuf); + } + + const scale = Math.min(imgAreaW / embeddedImg.width, imgAreaH / embeddedImg.height); + const drawW = embeddedImg.width * scale; + const drawH = embeddedImg.height * scale; + page.drawImage(embeddedImg, { + x: sx + 6 + (imgAreaW - drawW) / 2, + y: sy + (sh - drawH) / 2, + width: drawW, height: drawH, + }); + } catch { /* ignore embed errors */ } + } else { + // Placeholder if no image provided + page.drawRectangle({ x: sx + 6, y: sy + 8, width: imgAreaW, height: imgAreaH, color: C.bgGray, borderColor: rgb(0.8, 0.8, 0.85), borderWidth: 0.5 }); + page.drawText('Firma', { x: sx + 6 + imgAreaW / 2 - 10, y: sy + sh / 2 - 4, size: 7, font: f2, color: C.lightGray }); + } + + // Vertical divider + const divX = sx + imgAreaW + 8; + page.drawLine({ start: { x: divX, y: sy + 4 }, end: { x: divX, y: sy + sh - 4 }, thickness: 0.5, color: rgb(0.8, 0.8, 0.85) }); + + // Text right side + const textX = divX + 5; + const textW = sw - imgAreaW - 16; + const nameSize = signerName.length > 20 ? 7 : 8.5; + page.drawText(truncate(signerName, f1, nameSize, textW), { x: textX, y: sy + sh - 14, size: nameSize, font: f1, color: C.dark }); + page.drawLine({ start: { x: textX, y: sy + sh - 18 }, end: { x: textX + textW, y: sy + sh - 18 }, thickness: 0.4, color: C.green, opacity: 0.5 }); + if (reason) page.drawText(truncate(reason, f2, 6.5, textW), { x: textX, y: sy + sh - 29, size: 6.5, font: f2, color: C.gray }); + page.drawText(`${dateStr}`, { x: textX, y: sy + sh - 39, size: 6.5, font: f2, color: C.gray }); + page.drawText(`${timeStr}`, { x: textX, y: sy + sh - 49, size: 6.5, font: f2, color: C.gray }); + if (location) page.drawText(truncate(location, f2, 6, textW), { x: textX, y: sy + sh - 59, size: 6, font: f2, color: C.lightGray }); +} + +// ── Main sign function ───────────────────────────────────────────────────────── +const signPdf = async ({ + pdfPath, p12Buffer, p12Password, + signerName, reason = '', location = '', + page = 0, rect = null, + signatureStyle = 'classic', signatureImageBase64 = null, + qrPosition = 'left', + qrBackground = 'transparent', + qrBorder = 'none', + verificationUrl = null, +}) => { + const pdfBytes = await fs.readFile(pdfPath); + const pdfDoc = await PDFDocument.load(pdfBytes, { ignoreEncryption: true }); + const pages = pdfDoc.getPages(); + const tp = pages[Math.min(page, pages.length - 1)]; + const { width: pw, height: ph } = tp.getSize(); + const cropBox = typeof tp.getCropBox === 'function' + ? tp.getCropBox() + : { x: 0, y: 0, width: pw, height: ph }; + + const defaultWidth = getSignatureWidth(signatureStyle, signerName); + const requestedRect = rect || { + x: cropBox.width - (defaultWidth + 22), + y: 18, + width: defaultWidth, + height: 65, + }; + const resolvedWidth = requestedRect.width || defaultWidth; + const resolvedX = Math.min( + Math.max(requestedRect.x, 0), + Math.max(0, cropBox.width - resolvedWidth - 4) + ); + const resolvedY = Math.min( + Math.max(requestedRect.y, 0), + Math.max(0, cropBox.height - (requestedRect.height || 65) - 4) + ); + const sigRect = { + ...requestedRect, + x: resolvedX + cropBox.x, + y: resolvedY + cropBox.y, + width: resolvedWidth, + }; + 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 info = { signerName, reason, dateStr, timeStr, location, signatureImageBase64, verificationUrl, qrPosition, qrBackground, qrBorder }; + + // ── Draw visual stamp on page content stream ───────────────────── + const style = signatureStyle || 'classic'; + 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 await drawStyleClassic(pdfDoc, tp, sx, sy, sw, sh, info); + + // ── Save page with visual stamp ─────────────────────────────────── + const pdfWithStamp = Buffer.from(await pdfDoc.save({ useObjectStreams: false })); + + // ── Add cryptographic signature placeholder ─────────────────────── + const pdfDocForSign = await PDFDocument.load(pdfWithStamp); + pdflibAddPlaceholder({ + pdfDoc: pdfDocForSign, + 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), + }); + + const pdfWithPlaceholder = Buffer.from(await pdfDocForSign.save({ useObjectStreams: false })); + + // ── Sign ────────────────────────────────────────────────────────── + const signer = new P12Signer(p12Buffer, { passphrase: p12Password || '' }); + const signInstance = new SignPdf(); + const signedBuf = await signInstance.sign(pdfWithPlaceholder, signer); + + // ── Save ────────────────────────────────────────────────────────── + const destDir = await getSignedDocumentPath(); + const ext = path.extname(pdfPath); + const base = path.basename(pdfPath, ext).replace(/^\d+-/, ''); + const nowForFilename = new Date(); + const year = String(nowForFilename.getFullYear()); + const month = String(nowForFilename.getMonth() + 1).padStart(2, '0'); + const day = String(nowForFilename.getDate()).padStart(2, '0'); + 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 signedPath = path.join(destDir, signedFilename); + await fs.writeFile(signedPath, signedBuf); + + return { signedFilename, signedPath }; +}; + +module.exports = { signPdf }; diff --git a/backend/src/modules/signing/signing.test.js b/backend/src/modules/signing/signing.test.js new file mode 100644 index 0000000..0f5ef3a --- /dev/null +++ b/backend/src/modules/signing/signing.test.js @@ -0,0 +1,25 @@ +const fs = require('fs/promises'); +const path = require('path'); +const { loadCertificate } = require('./signing.service'); + +const run = async () => { + try { + const certificatePath = process.argv[2]; + const password = process.argv[3] || ''; + + if (!certificatePath) { + throw new Error('Certificate path is required.'); + } + + const filePath = path.resolve(certificatePath); + const pfxBuffer = await fs.readFile(filePath); + const result = await loadCertificate(pfxBuffer, password); + + console.log('privateKey exists:', Boolean(result.privateKey)); + console.log('certificate exists:', Boolean(result.certificate)); + } catch (error) { + console.error('Error loading certificate:', error.message); + } +}; + +run(); diff --git a/backend/src/modules/users/.gitkeep b/backend/src/modules/users/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/src/modules/users/.gitkeep @@ -0,0 +1 @@ + diff --git a/backend/src/modules/users/users.controller.js b/backend/src/modules/users/users.controller.js new file mode 100644 index 0000000..d9d1b30 --- /dev/null +++ b/backend/src/modules/users/users.controller.js @@ -0,0 +1,15 @@ +const usersService = require('./users.service'); +const { sendSuccess } = require('../../utils/apiResponse'); + +const updateSignatureAppearancePreference = async (req, res, next) => { + try { + const signaturePreferences = await usersService.updateSignaturePreferences(req.user.id, req.body); + return sendSuccess(res, { signaturePreferences }, 200); + } catch (error) { + return next(error); + } +}; + +module.exports = { + updateSignatureAppearancePreference, +}; diff --git a/backend/src/modules/users/users.repository.js b/backend/src/modules/users/users.repository.js new file mode 100644 index 0000000..4523507 --- /dev/null +++ b/backend/src/modules/users/users.repository.js @@ -0,0 +1,66 @@ +const fs = require('fs/promises'); +const path = require('path'); + +const preferencesDir = path.resolve(process.cwd(), 'storage', 'preferences'); +const preferencesFile = path.join(preferencesDir, 'signature-styles.json'); + +const normalizeSignaturePreferences = (value) => { + if (!value) { + return { + signatureStyle: 'qr', + qrPosition: 'left', + qrBackground: 'transparent', + qrBorder: 'none', + signatureImageBase64: null, + }; + } + + if (typeof value === 'string') { + return { + signatureStyle: value, + qrPosition: 'left', + qrBackground: 'transparent', + qrBorder: 'none', + signatureImageBase64: null, + }; + } + + return { + signatureStyle: value.signatureStyle || 'qr', + qrPosition: value.qrPosition || 'left', + qrBackground: value.qrBackground || 'transparent', + qrBorder: value.qrBorder || 'none', + signatureImageBase64: value.signatureImageBase64 || null, + }; +}; + +const readPreferences = async () => { + try { + const raw = await fs.readFile(preferencesFile, 'utf8'); + return JSON.parse(raw); + } catch (error) { + return {}; + } +}; + +const writePreferences = async (data) => { + await fs.mkdir(preferencesDir, { recursive: true }); + await fs.writeFile(preferencesFile, JSON.stringify(data, null, 2), 'utf8'); +}; + +const getSignaturePreferences = async (userId) => { + const preferences = await readPreferences(); + return normalizeSignaturePreferences(preferences[String(userId)]); +}; + +const setSignaturePreferences = async (userId, signaturePreferences) => { + const preferences = await readPreferences(); + preferences[String(userId)] = normalizeSignaturePreferences(signaturePreferences); + await writePreferences(preferences); + return preferences[String(userId)]; +}; + +module.exports = { + getSignaturePreferences, + setSignaturePreferences, +}; diff --git a/backend/src/modules/users/users.routes.js b/backend/src/modules/users/users.routes.js new file mode 100644 index 0000000..4a8ea7b --- /dev/null +++ b/backend/src/modules/users/users.routes.js @@ -0,0 +1,11 @@ +const express = require('express'); +const authenticate = require('../../middlewares/authenticate'); +const usersController = require('./users.controller'); + +const router = express.Router(); + +router.use(authenticate); + +router.patch('/me/preferences/signature-appearance', usersController.updateSignatureAppearancePreference); + +module.exports = router; diff --git a/backend/src/modules/users/users.service.js b/backend/src/modules/users/users.service.js new file mode 100644 index 0000000..8cbf5a1 --- /dev/null +++ b/backend/src/modules/users/users.service.js @@ -0,0 +1,49 @@ +const usersRepository = require('./users.repository'); + +const ALLOWED_SIGNATURE_STYLES = ['classic', 'qr', 'professional', 'image']; +const ALLOWED_QR_POSITIONS = ['left', 'right']; +const ALLOWED_QR_BACKGROUNDS = ['transparent', 'soft']; +const ALLOWED_QR_BORDERS = ['none', 'line']; + +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; + } + + 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; + } + + return usersRepository.setSignaturePreferences(userId, { + signatureStyle, + qrPosition, + qrBackground, + qrBorder, + signatureImageBase64: signatureImageBase64 || null, + }); +}; + +module.exports = { + getSignaturePreferences, + updateSignaturePreferences, +}; diff --git a/backend/src/routes/index.js b/backend/src/routes/index.js new file mode 100644 index 0000000..1fe1599 --- /dev/null +++ b/backend/src/routes/index.js @@ -0,0 +1,18 @@ +const express = require('express'); +const authRoutes = require('../modules/auth/auth.routes'); +const certificatesRoutes = require('../modules/certificates/certificates.routes'); +const documentsRoutes = require('../modules/documents/documents.routes'); +const signaturesRoutes = require('../modules/signatures/signatures.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('/certificates', certificatesRoutes); +router.use('/documents', documentsRoutes); +router.use('/signatures', signaturesRoutes); +router.use('/users', usersRoutes); +router.use('/health', healthRoutes); + +module.exports = router; diff --git a/backend/src/server.js b/backend/src/server.js new file mode 100644 index 0000000..7ec9a11 --- /dev/null +++ b/backend/src/server.js @@ -0,0 +1,6 @@ +const app = require('./app'); +const env = require('./config/env'); + +app.listen(env.port, () => { + console.log(`LinkedSign backend running on port ${env.port}`); +}); diff --git a/backend/src/utils/apiResponse.js b/backend/src/utils/apiResponse.js new file mode 100644 index 0000000..843ce58 --- /dev/null +++ b/backend/src/utils/apiResponse.js @@ -0,0 +1,20 @@ +const sendSuccess = (res, data, statusCode = 200) => { + return res.status(statusCode).json({ + success: true, + data, + error: null, + }); +}; + +const sendError = (res, error, statusCode = 400) => { + return res.status(statusCode).json({ + success: false, + data: null, + error, + }); +}; + +module.exports = { + sendSuccess, + sendError, +}; diff --git a/backend/src/utils/certificatePath.js b/backend/src/utils/certificatePath.js new file mode 100644 index 0000000..6fcef45 --- /dev/null +++ b/backend/src/utils/certificatePath.js @@ -0,0 +1,17 @@ +const fs = require('fs/promises'); +const path = require('path'); + +const getUserCertificatePath = async (userId) => { + const fullPath = path.resolve( + '/home/mgonzalez/desarrollo/react/linkedsign/backend/storage/certificates/users', + String(userId) + ); + + await fs.mkdir(fullPath, { recursive: true }); + + return fullPath; +}; + +module.exports = { + getUserCertificatePath, +}; diff --git a/backend/src/utils/jwt.js b/backend/src/utils/jwt.js new file mode 100644 index 0000000..f19fdb3 --- /dev/null +++ b/backend/src/utils/jwt.js @@ -0,0 +1,16 @@ +const jwt = require('jsonwebtoken'); +const env = require('../config/env'); + +const signAccess = (payload) => { + return jwt.sign(payload, env.jwt.secret, { expiresIn: env.jwt.accessExpires }); +}; + +const signRefresh = (payload) => { + return jwt.sign(payload, env.jwt.secret, { expiresIn: env.jwt.refreshExpires }); +}; + +const verify = (token) => { + return jwt.verify(token, env.jwt.secret); +}; + +module.exports = { signAccess, signRefresh, verify }; diff --git a/backend/src/utils/mockJwt.js b/backend/src/utils/mockJwt.js new file mode 100644 index 0000000..5b1c408 --- /dev/null +++ b/backend/src/utils/mockJwt.js @@ -0,0 +1,31 @@ +const toBase64Url = (value) => { + return Buffer.from(value) + .toString('base64') + .replace(/=/g, '') + .replace(/\+/g, '-') + .replace(/\//g, '_'); +}; + +const createMockJwt = (payload = {}) => { + const header = { + alg: 'HS256', + typ: 'JWT', + }; + + const body = { + sub: payload.email || 'mock-user', + role: 'user', + iat: Math.floor(Date.now() / 1000), + mock: true, + }; + + return [ + toBase64Url(JSON.stringify(header)), + toBase64Url(JSON.stringify(body)), + 'mock-signature', + ].join('.'); +}; + +module.exports = { + createMockJwt, +}; diff --git a/backend/src/utils/storagePath.js b/backend/src/utils/storagePath.js new file mode 100644 index 0000000..0c7d87c --- /dev/null +++ b/backend/src/utils/storagePath.js @@ -0,0 +1,29 @@ +const fs = require('fs/promises'); +const path = require('path'); +const env = require('../config/env'); + +const getDocumentPath = async () => { + const now = new Date(); + const year = String(now.getFullYear()); + const month = String(now.getMonth() + 1).padStart(2, '0'); + const fullPath = path.join(env.storagePath, 'documents', year, month); + await fs.mkdir(fullPath, { recursive: true }); + return fullPath; +}; + +const getSignedDocumentPath = async () => { + const now = new Date(); + const year = String(now.getFullYear()); + const month = String(now.getMonth() + 1).padStart(2, '0'); + const fullPath = path.join(env.storagePath, 'signed', year, month); + await fs.mkdir(fullPath, { recursive: true }); + return fullPath; +}; + +const getCertificatePath = async (userId) => { + const fullPath = path.join(env.storagePath, 'certificates', String(userId)); + await fs.mkdir(fullPath, { recursive: true }); + return fullPath; +}; + +module.exports = { getDocumentPath, getSignedDocumentPath, getCertificatePath }; diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..1ddb88c --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,98 @@ +# LinkedSign Frontend + +Aplicación web React + Vite para LinkedSign. Se complementa con el backend REST y con la app Android. + +## Módulos visibles + +- autenticación +- documentos +- certificados +- estilo de firma +- firmar +- verificación pública + +## Flujo actual + +### Autenticación + +- registro con correo y contraseña segura +- validación por enlace enviado al correo +- reenvío automático del enlace si el usuario intenta iniciar sesión sin validar +- cambio de contraseña desde el bloque del usuario del menú lateral +- confirmación de cambio de contraseña por correo + +### Documentos + +En el menú **Documentos** existen dos tablas: + +1. **Documentos originales para firmar** + - subir PDF + - ver original + - descargar original + - enviar a firmar + - eliminar + +2. **Documentos firmados generados** + - ver el PDF firmado antes de descargar + - descargar el PDF firmado + +### Certificados + +- subir `.p12` / `.pfx` +- ver sujeto, emisor y vigencia +- ver si el archivo incluye cadena embebida + +### Estilo Firma + +El menú **Estilo Firma** centraliza la configuración visual del usuario: + +- clásico +- QR +- profesional +- imagen + +Para QR: + +- izquierda / derecha +- fondo transparente o suave +- sin borde o con borde + +La preferencia se reutiliza en futuras firmas. + +### Firmar + +El menú **Firmar** usa: + +- documento original +- certificado activo +- razón +- ubicación +- posición visible elegida sobre el PDF +- configuración visual guardada en **Estilo Firma** + +### Verificación pública + +- las firmas con QR apuntan a una URL pública de verificación +- la pantalla muestra si la firma sigue siendo válida dentro del registro de LinkedSign + +## Desarrollo + +```bash +cd /home/mgonzalez/desarrollo/react/linkedsign/frontend +npm install +npm run dev +``` + +## Build + +```bash +npm run build +``` + +## Relación con Android + +La app móvil en [android/README.md](/home/mgonzalez/desarrollo/react/linkedsign/android/README.md): + +- usa la misma autenticación +- consume el mismo backend +- sincroniza documentos, certificados y firmados bajo petición diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js new file mode 100644 index 0000000..4fa125d --- /dev/null +++ b/frontend/eslint.config.js @@ -0,0 +1,29 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import { defineConfig, globalIgnores } from 'eslint/config' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{js,jsx}'], + extends: [ + js.configs.recommended, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + ], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + parserOptions: { + ecmaVersion: 'latest', + ecmaFeatures: { jsx: true }, + sourceType: 'module', + }, + }, + rules: { + 'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }], + }, + }, +]) diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..f94d687 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + frontend + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..dc02b05 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,3352 @@ +{ + "name": "frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend", + "version": "0.0.0", + "dependencies": { + "axios": "^1.14.0", + "lucide-react": "^1.7.0", + "react": "^19.2.4", + "react-dom": "^19.2.4", + "react-hot-toast": "^2.6.0", + "react-pdf": "^10.4.1", + "react-router-dom": "^7.14.0" + }, + "devDependencies": { + "@eslint/js": "^9.39.4", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "eslint": "^9.39.4", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.5.2", + "globals": "^17.4.0", + "vite": "^8.0.1" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", + "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", + "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/canvas": { + "version": "0.1.97", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.97.tgz", + "integrity": "sha512-8cFniXvrIEnVwuNSRCW9wirRZbHvrD3JVujdS2P5n5xiJZNZMOZcfOvJ1pb66c7jXMKHHglJEDVJGbm8XWFcXQ==", + "license": "MIT", + "optional": true, + "workspaces": [ + "e2e/*" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "optionalDependencies": { + "@napi-rs/canvas-android-arm64": "0.1.97", + "@napi-rs/canvas-darwin-arm64": "0.1.97", + "@napi-rs/canvas-darwin-x64": "0.1.97", + "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.97", + "@napi-rs/canvas-linux-arm64-gnu": "0.1.97", + "@napi-rs/canvas-linux-arm64-musl": "0.1.97", + "@napi-rs/canvas-linux-riscv64-gnu": "0.1.97", + "@napi-rs/canvas-linux-x64-gnu": "0.1.97", + "@napi-rs/canvas-linux-x64-musl": "0.1.97", + "@napi-rs/canvas-win32-arm64-msvc": "0.1.97", + "@napi-rs/canvas-win32-x64-msvc": "0.1.97" + } + }, + "node_modules/@napi-rs/canvas-android-arm64": { + "version": "0.1.97", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.97.tgz", + "integrity": "sha512-V1c/WVw+NzH8vk7ZK/O8/nyBSCQimU8sfMsB/9qeSvdkGKNU7+mxy/bIF0gTgeBFmHpj30S4E9WHMSrxXGQuVQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-darwin-arm64": { + "version": "0.1.97", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.97.tgz", + "integrity": "sha512-ok+SCEF4YejcxuJ9Rm+WWunHHpf2HmiPxfz6z1a/NFQECGXtsY7A4B8XocK1LmT1D7P174MzwPF9Wy3AUAwEPw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-darwin-x64": { + "version": "0.1.97", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.97.tgz", + "integrity": "sha512-PUP6e6/UGlclUvAQNnuXCcnkpdUou6VYZfQOQxExLp86epOylmiwLkqXIvpFmjoTEDmPmXrI+coL/9EFU1gKPA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": { + "version": "0.1.97", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.97.tgz", + "integrity": "sha512-XyXH2L/cic8eTNtbrXCcvqHtMX/nEOxN18+7rMrAM2XtLYC/EB5s0wnO1FsLMWmK+04ZSLN9FBGipo7kpIkcOw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-gnu": { + "version": "0.1.97", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.97.tgz", + "integrity": "sha512-Kuq/M3djq0K8ktgz6nPlK7Ne5d4uWeDxPpyKWOjWDK2RIOhHVtLtyLiJw2fuldw7Vn4mhw05EZXCEr4Q76rs9w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-musl": { + "version": "0.1.97", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.97.tgz", + "integrity": "sha512-kKmSkQVnWeqg7qdsiXvYxKhAFuHz3tkBjW/zyQv5YKUPhotpaVhpBGv5LqCngzyuRV85SXoe+OFj+Tv0a0QXkQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-riscv64-gnu": { + "version": "0.1.97", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.97.tgz", + "integrity": "sha512-Jc7I3A51jnEOIAXeLsN/M/+Z28LUeakcsXs07FLq9prXc0eYOtVwsDEv913Gr+06IRo34gJJVgT0TXvmz+N2VA==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-gnu": { + "version": "0.1.97", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.97.tgz", + "integrity": "sha512-iDUBe7AilfuBSRbSa8/IGX38Mf+iCSBqoVKLSQ5XaY2JLOaqz1TVyPFEyIck7wT6mRQhQt5sN6ogfjIDfi74tg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-musl": { + "version": "0.1.97", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.97.tgz", + "integrity": "sha512-AKLFd/v0Z5fvgqBDqhvqtAdx+fHMJ5t9JcUNKq4FIZ5WH+iegGm8HPdj00NFlCSnm83Fp3Ln8I2f7uq1aIiWaA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-win32-arm64-msvc": { + "version": "0.1.97", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-0.1.97.tgz", + "integrity": "sha512-u883Yr6A6fO7Vpsy9YE4FVCIxzzo5sO+7pIUjjoDLjS3vQaNMkVzx5bdIpEL+ob+gU88WDK4VcxYMZ6nmnoX9A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-win32-x64-msvc": { + "version": "0.1.97", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.97.tgz", + "integrity": "sha512-sWtD2EE3fV0IzN+iiQUqr/Q1SwqWhs2O1FKItFlxtdDkikpEj5g7DKQpY3x55H/MAOnL8iomnlk3mcEeGiUMoQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.2.tgz", + "integrity": "sha512-sNXv5oLJ7ob93xkZ1XnxisYhGYXfaG9f65/ZgYuAu3qt7b3NadcOEhLvx28hv31PgX8SZJRYrAIPQilQmFpLVw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.122.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.122.0.tgz", + "integrity": "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.12.tgz", + "integrity": "sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.12.tgz", + "integrity": "sha512-cFYr6zTG/3PXXF3pUO+umXxt1wkRK/0AYT8lDwuqvRC+LuKYWSAQAQZjCWDQpAH172ZV6ieYrNnFzVVcnSflAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.12.tgz", + "integrity": "sha512-ZCsYknnHzeXYps0lGBz8JrF37GpE9bFVefrlmDrAQhOEi4IOIlcoU1+FwHEtyXGx2VkYAvhu7dyBf75EJQffBw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.12.tgz", + "integrity": "sha512-dMLeprcVsyJsKolRXyoTH3NL6qtsT0Y2xeuEA8WQJquWFXkEC4bcu1rLZZSnZRMtAqwtrF/Ib9Ddtpa/Gkge9Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.12.tgz", + "integrity": "sha512-YqWjAgGC/9M1lz3GR1r1rP79nMgo3mQiiA+Hfo+pvKFK1fAJ1bCi0ZQVh8noOqNacuY1qIcfyVfP6HoyBRZ85Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.12.tgz", + "integrity": "sha512-/I5AS4cIroLpslsmzXfwbe5OmWvSsrFuEw3mwvbQ1kDxJ822hFHIx+vsN/TAzNVyepI/j/GSzrtCIwQPeKCLIg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.12.tgz", + "integrity": "sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.12.tgz", + "integrity": "sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.12.tgz", + "integrity": "sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.12.tgz", + "integrity": "sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.12.tgz", + "integrity": "sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.12.tgz", + "integrity": "sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.12.tgz", + "integrity": "sha512-ykGiLr/6kkiHc0XnBfmFJuCjr5ZYKKofkx+chJWDjitX+KsJuAmrzWhwyOMSHzPhzOHOy7u9HlFoa5MoAOJ/Zg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^1.1.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.12.tgz", + "integrity": "sha512-5eOND4duWkwx1AzCxadcOrNeighiLwMInEADT0YM7xeEOOFcovWZCq8dadXgcRHSf3Ulh1kFo/qvzoFiCLOL1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.12.tgz", + "integrity": "sha512-PyqoipaswDLAZtot351MLhrlrh6lcZPo2LSYE+VDxbVk24LVKAGOuE4hb8xZQmrPAuEtTZW8E6D2zc5EUZX4Lw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.7", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.7.tgz", + "integrity": "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz", + "integrity": "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "1.0.0-rc.7" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.14.0.tgz", + "integrity": "sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.14", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.14.tgz", + "integrity": "sha512-fOVLPAsFTsQfuCkvahZkzq6nf8KvGWanlYoTh0SVA0A/PIUxQGU2AOZAoD95n2gFLVDW/jP6sbGLny95nmEuHA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001785", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001785.tgz", + "integrity": "sha512-blhOL/WNR+Km1RI/LCVAvA73xplXA7ZbjzI4YkMK9pa6T/P3F2GxjNpEkyw5repTw9IvkyrjyHpwjnhZ5FOvYQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.331", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.331.tgz", + "integrity": "sha512-IbxXrsTlD3hRodkLnbxAPP4OuJYdWCeM3IOdT+CpcMoIwIoDfCmRpEtSPfwBXxVkg9xmBeY7Lz2Eo2TDn/HC3Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", + "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz", + "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.2.tgz", + "integrity": "sha512-hmgTH57GfzoTFjVN0yBwTggnsVUF2tcqi7RJZHqi9lIezSs4eFyAMktA68YD4r5kNw1mxyY4dmkyoFDb3FIqrA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": "^9 || ^10" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "17.4.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.4.0.tgz", + "integrity": "sha512-hjrNztw/VajQwOLsMNT1cbJiH2muO3OROCHnbehc8eY5JyD2gqz4AcMHPqgaOR59DjgUjYAYLeH699g/eWi2jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/goober": { + "version": "2.1.18", + "resolved": "https://registry.npmjs.org/goober/-/goober-2.1.18.tgz", + "integrity": "sha512-2vFqsaDVIT9Gz7N6kAL++pLpp41l3PfDuusHcjnGLfR6+huZkl6ziX+zgVC3ZxpqWhzH6pyDdGrCeDhMIvwaxw==", + "license": "MIT", + "peerDependencies": { + "csstype": "^3.0.10" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.7.0.tgz", + "integrity": "sha512-yI7BeItCLZJTXikmK4KNUGCKoGzSvbKlfCvw44bU4fXAL6v3gYS4uHD1jzsLkfwODYwI6Drw5Tu9Z5ulDe0TSg==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/make-cancellable-promise": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/make-cancellable-promise/-/make-cancellable-promise-2.0.0.tgz", + "integrity": "sha512-3SEQqTpV9oqVsIWqAcmDuaNeo7yBO3tqPtqGRcKkEo0lrzD3wqbKG9mkxO65KoOgXqj+zH2phJ2LiAsdzlogSw==", + "license": "MIT", + "funding": { + "url": "https://github.com/wojtekmaj/make-cancellable-promise?sponsor=1" + } + }, + "node_modules/make-event-props": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/make-event-props/-/make-event-props-2.0.0.tgz", + "integrity": "sha512-G/hncXrl4Qt7mauJEXSg3AcdYzmpkIITTNl5I+rH9sog5Yw0kK6vseJjCaPfOXqOqQuPUP89Rkhfz5kPS8ijtw==", + "license": "MIT", + "funding": { + "url": "https://github.com/wojtekmaj/make-event-props?sponsor=1" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/merge-refs": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-refs/-/merge-refs-2.0.0.tgz", + "integrity": "sha512-3+B21mYK2IqUWnd2EivABLT7ueDhb0b8/dGK8LoFQPrU61YITeCMn14F7y7qZafWNZhUEKb24cJdiT5Wxs3prg==", + "license": "MIT", + "funding": { + "url": "https://github.com/wojtekmaj/merge-refs?sponsor=1" + }, + "peerDependencies": { + "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.37", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz", + "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==", + "dev": true, + "license": "MIT" + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pdfjs-dist": { + "version": "5.4.296", + "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.4.296.tgz", + "integrity": "sha512-DlOzet0HO7OEnmUmB6wWGJrrdvbyJKftI1bhMitK7O2N8W2gc757yyYBbINy9IDafXAV9wmKr9t7xsTaNKRG5Q==", + "license": "Apache-2.0", + "engines": { + "node": ">=20.16.0 || >=22.3.0" + }, + "optionalDependencies": { + "@napi-rs/canvas": "^0.1.80" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.4" + } + }, + "node_modules/react-hot-toast": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.6.0.tgz", + "integrity": "sha512-bH+2EBMZ4sdyou/DPrfgIouFpcRLCJ+HoCA32UoAYHn6T3Ur5yfcDCeSr5mwldl6pFOsiocmrXMuoCJ1vV8bWg==", + "license": "MIT", + "dependencies": { + "csstype": "^3.1.3", + "goober": "^2.1.16" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": ">=16", + "react-dom": ">=16" + } + }, + "node_modules/react-pdf": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/react-pdf/-/react-pdf-10.4.1.tgz", + "integrity": "sha512-kS/35staVCBqS29verTQJQZXw7RfsRCPO3fdJoW1KXylcv7A9dw6DZ3vJXC2w+bIBgLw5FN4pOFvKSQtkQhPfA==", + "license": "MIT", + "dependencies": { + "clsx": "^2.0.0", + "dequal": "^2.0.3", + "make-cancellable-promise": "^2.0.0", + "make-event-props": "^2.0.0", + "merge-refs": "^2.0.0", + "pdfjs-dist": "5.4.296", + "tiny-invariant": "^1.0.0", + "warning": "^4.0.0" + }, + "funding": { + "url": "https://github.com/wojtekmaj/react-pdf?sponsor=1" + }, + "peerDependencies": { + "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-router": { + "version": "7.14.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.14.0.tgz", + "integrity": "sha512-m/xR9N4LQLmAS0ZhkY2nkPA1N7gQ5TUVa5n8TgANuDTARbn1gt+zLPXEm7W0XDTbrQ2AJSJKhoa6yx1D8BcpxQ==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.14.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.14.0.tgz", + "integrity": "sha512-2G3ajSVSZMEtmTjIklRWlNvo8wICEpLihfD/0YMDxbWK2UyP5EGfnoIn9AIQGnF3G/FX0MRbHXdFcD+rL1ZreQ==", + "license": "MIT", + "dependencies": { + "react-router": "7.14.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/rolldown": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.12.tgz", + "integrity": "sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.122.0", + "@rolldown/pluginutils": "1.0.0-rc.12" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0-rc.12", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.12", + "@rolldown/binding-darwin-x64": "1.0.0-rc.12", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.12", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.12", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.12", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.12", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.12", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.12", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.12", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.12" + } + }, + "node_modules/rolldown/node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.12.tgz", + "integrity": "sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw==", + "dev": true, + "license": "MIT" + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vite": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.3.tgz", + "integrity": "sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.8", + "rolldown": "1.0.0-rc.12", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.0", + "esbuild": "^0.27.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/warning": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", + "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..6f3c402 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,32 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "axios": "^1.14.0", + "lucide-react": "^1.7.0", + "react": "^19.2.4", + "react-dom": "^19.2.4", + "react-hot-toast": "^2.6.0", + "react-pdf": "^10.4.1", + "react-router-dom": "^7.14.0" + }, + "devDependencies": { + "@eslint/js": "^9.39.4", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "eslint": "^9.39.4", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.5.2", + "globals": "^17.4.0", + "vite": "^8.0.1" + } +} diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..6893eb1 --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/icons.svg b/frontend/public/icons.svg new file mode 100644 index 0000000..e952219 --- /dev/null +++ b/frontend/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/App.css b/frontend/src/App.css new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/frontend/src/App.css @@ -0,0 +1 @@ + diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx new file mode 100644 index 0000000..1909cd3 --- /dev/null +++ b/frontend/src/App.jsx @@ -0,0 +1,54 @@ +import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; +import { Toaster } from 'react-hot-toast'; +import { AuthProvider } from './contexts/AuthContext'; +import PrivateRoute from './components/ui/PrivateRoute'; +import Layout from './components/layout/Layout'; +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 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'; + +export default function App() { + return ( + + + + + } /> + } /> + } /> + } /> + } /> + } /> + } + /> + } + /> + } + /> + } + /> + } + /> + } /> + + + + ); +} diff --git a/frontend/src/api/auth.js b/frontend/src/api/auth.js new file mode 100644 index 0000000..0e652c7 --- /dev/null +++ b/frontend/src/api/auth.js @@ -0,0 +1,9 @@ +import client from './client'; + +export const register = (data) => client.post('/auth/register', data); +export const login = (data) => client.post('/auth/login', data); +export const logout = (refreshToken) => client.post('/auth/logout', { refreshToken }); +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}`); diff --git a/frontend/src/api/certificates.js b/frontend/src/api/certificates.js new file mode 100644 index 0000000..dbde866 --- /dev/null +++ b/frontend/src/api/certificates.js @@ -0,0 +1,12 @@ +import client from './client'; + +export const uploadCertificate = (file, password, alias) => { + const form = new FormData(); + form.append('file', file); + if (password) form.append('password', password); + if (alias) form.append('alias', alias); + return client.post('/certificates', form, { headers: { 'Content-Type': 'multipart/form-data' } }); +}; + +export const listCertificates = () => client.get('/certificates'); +export const deleteCertificate = (id) => client.delete(`/certificates/${id}`); diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js new file mode 100644 index 0000000..c3b8411 --- /dev/null +++ b/frontend/src/api/client.js @@ -0,0 +1,42 @@ +import axios from 'axios'; + +const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:4000'; + +const client = axios.create({ + baseURL: API_URL, + headers: { 'Content-Type': 'application/json' }, +}); + +client.interceptors.request.use((config) => { + const token = localStorage.getItem('accessToken'); + if (token) config.headers.Authorization = `Bearer ${token}`; + return config; +}); + +client.interceptors.response.use( + (res) => res, + async (error) => { + const original = error.config; + if (error.response?.status === 401 && !original._retry) { + original._retry = true; + const refreshToken = localStorage.getItem('refreshToken'); + if (refreshToken) { + 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); + original.headers.Authorization = `Bearer ${accessToken}`; + return client(original); + } catch { + localStorage.removeItem('accessToken'); + localStorage.removeItem('refreshToken'); + window.location.href = '/login'; + } + } + } + return Promise.reject(error); + } +); + +export default client; diff --git a/frontend/src/api/documents.js b/frontend/src/api/documents.js new file mode 100644 index 0000000..914677d --- /dev/null +++ b/frontend/src/api/documents.js @@ -0,0 +1,12 @@ +import client from './client'; + +export const uploadDocument = (file) => { + const form = new FormData(); + form.append('file', file); + return client.post('/documents', form, { headers: { 'Content-Type': 'multipart/form-data' } }); +}; + +export const listDocuments = (params) => client.get('/documents', { params }); +export const getDocument = (id) => client.get(`/documents/${id}`); +export const deleteDocument = (id) => client.delete(`/documents/${id}`); +export const downloadDocument = (id) => client.get(`/documents/${id}/download`, { responseType: 'blob' }); diff --git a/frontend/src/api/public.js b/frontend/src/api/public.js new file mode 100644 index 0000000..50c47b8 --- /dev/null +++ b/frontend/src/api/public.js @@ -0,0 +1,5 @@ +import axios from 'axios'; + +const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:4000'; + +export const verifySignature = (code) => axios.get(`${API_URL}/signatures/verify/${code}`); diff --git a/frontend/src/api/signatures.js b/frontend/src/api/signatures.js new file mode 100644 index 0000000..3496a2e --- /dev/null +++ b/frontend/src/api/signatures.js @@ -0,0 +1,6 @@ +import client from './client'; + +export const signDocument = (data) => client.post('/signatures/sign', data); +export const listSignedDocuments = () => client.get('/signatures/mine'); +export const getSignatures = (documentId) => client.get(`/signatures/document/${documentId}`); +export const downloadSigned = (sigId) => client.get(`/signatures/${sigId}/download`, { responseType: 'blob' }); diff --git a/frontend/src/api/users.js b/frontend/src/api/users.js new file mode 100644 index 0000000..36fcca6 --- /dev/null +++ b/frontend/src/api/users.js @@ -0,0 +1,4 @@ +import client from './client'; + +export const updateSignatureAppearancePreference = (signaturePreferences) => + client.patch('/users/me/preferences/signature-appearance', signaturePreferences); diff --git a/frontend/src/assets/hero.png b/frontend/src/assets/hero.png new file mode 100644 index 0000000000000000000000000000000000000000..cc51a3d20ad4bc961b596a6adfd686685cd84bb0 GIT binary patch literal 44919 zcma%i^5TDbT`tlgo2c`(n!ND-Q6MGAYIbZ-QCh5-QC^YozK_ne*b_MKK#O- zIWy zd$aJVZ?rl%;eiC7d#Sl-cWLv9rA0(UOX(@I3k&yyL+3GaQ4xpb1EGC|i|{byaTI># zBO=0pyZu5XO!hzGNPch4cx%6XJAJpDa<+98BOcYNo1=XER1sv!UW z^>ZDMp%FSmVnt)n^EIR+Nth`vRO^_=UF3EWv75ym{S;#2F8MPot@-y$>ioj!)a1bE zijXPQY;U`qNwl9|wl{W>{FhMSb<>m4{;8Udp4psl)NwFRo(W-T)Y6-qDf=L#U?g<@ zV+T|3+RuE~!E&nodKrkfPcOpJ)&1|p`Tbtd12@MSE8DjWkD|9M>GZsHLf>TTbLx)B z#5K5l%gS7s(yWk?Lj{Nvm`Z-s8xb-Xr`5-xRr%w8v>!oSz{dN*MmxbscQl#Z40qSd z!PQXs-utLEF&$@S#__Lo*pOhG{l(%jyCh-0ME8owiT>U~r&q@MaDRePL(aZAAff9= zBd@*7RZxmiqK^nZH7`bTjIEQw#Y=V6(h{$>7ZIf=7S0;$8~4NXLd4T;Ai~C8&3k-; zYEtJWq6x$#5rrCJ%zspgO z((R)&>BIkkr^qQSEZljO*B+ZDvTeBKJ9N%8Ej=U+62GI)dc|ZMEM66~W12v&QFAIS zoDs`J`wjsl?WdE(NTnjCO!^yB>{yU-2UPT`&FOyVQVmxy#un2Po>GiPPfzd0M^d_i z+Kr}dPhIfsDLd~jOiJ(sHTN;2u)@MaX&0AdXR;BAwr_;1sR;)MM+&{XTzNnKWH@0a zoy9ApaUt=>jjHICu3W42)5;nzHS!M3?aOvZfv-sIc%wc9#l0uHFc}aS4JSrIDOQ?4ri_bS?pjH{U{6qr+6m z--%u=5oc&PxE==-I$~$5gw}yiu_y_o?|ag2+rAgSg%G)}EU}r%*A|v|pjbE`lxJpU zy0{?;(US(i-TiKq6s_(KTYy|YVi&!plMT)EJ4wMU{C7Y;!Xow1nJ+X@ks@r0v25R; z*o$8AP*G*f3$UlYR~18PxKyPj9vU#v)4#GgEx4*?KOhlh>0%3M$-LN7&b*0fXgm$k zH78>bObkx^3_K+RY;G+Usy6L}p9iT!hlnJCmR=;=JL1TdtB#vL!RTJ1TABQx8Ux0w zl^{Jkf(hU>-jr59iK_v-PkV!WwG!LvW<@{3{IbbSiWBrX@S8^`8JFRrc+(AqsUIvm zCTstACtCZ~qy-5^Gr@_z#X!N1*1vH=7@8oL4AEOxWl^YW&LW|1$1J?gG061vk1epe zRI_*s(lrX?-2#tCt_`)p?{zZC+)onl60CU~%4!vPA}h0+fB9ucNkTQ3u29((9Wq=> z^JUm|{_2-=?dMKu&9)#x{lgPOCM`U1^tXDbmZ%I$0fw7|Y-@3Tyj1LGfk$lvzYC85 z=R()QEER%Dz=mTMZ=7E?K74&?)4b~-uj34rKwb~7vU(48%+1xYc^VYn| zncI4NL8xEnmi>eM9EK&~si%*s|BX@zKIUU?cAWA5pdc`xEZIF1Ce=Wcg3#AP?N~p# zD7mfb{oR=ZPE^jgwD3G< z#8h1K&u&zKD4q*Pxt0ta#d}bm;QqZ!hFift22a~7c529SkmFQyN-*H zzQck2cL5iH2@d@Lhq4$~_!wMWL6(&mNq=7HhT}YYI$pVVZeQr>)4>qObE$PPNZ2!0 z&7?y_upwfiefj8-`B$ju)}QKTz*Zs<$Lb?XHBo(jyU(405&`EL({mgxA$Ov49U|rN z2@(l@n`1vzG(v=!u4AZ*0s}~H4{VgcNOJ1rB?Kg!=)mGHKWeC|MHb>aiQ4Qd+gq7|??WH7;?J+kYL8z# z@juTBhW#n3rN))N7T1~)qr~Es;2rln6_U>_Ejxj(E5%Cpoc^vfw64mua!ADSZ8i|+ zB}g?u(dtvesTegnG!9K33T)4eq>)>ZFp?L>R8Qp#(J=bxz2mscD;ZNoJB@ZUqPpI>o7VgScniW4c()#;@;-9PfR`b(r+#4c; z;1-)`!?b}4A3v^zVtGa(a;O%bzu(ZG;(l4+W^vU|a&n*xV0kU$uFQ!5!aWy)^q4^r zn!-6hfj79_B#>GGNvQiKMD?xyW>F&GS>3y?Ric*xp4cz3FH3Gd1z|e+Vuug7*Ya48 zL~K*l5zo1XRuWm%S~GzE4LQyuRsH1&L`Gz-%>!ZTYn9K_Ttz+Pa@9hKob^)gmLVN` zKJz}C50X$$>G1Q_p;%C}B?<9h`60%vwalt2*Ymd44dGF(oOa2mJQuPQmE~Yurn0UC z6(+5$posAd@e$nvJQFL^C~E0E4IH`B68)j#L_u|Ex5mNE8a8{>gAGcIFVS|K?g77# zE@R|9nR>Rw3(5}{d~HnPpooZ*XZC$5FYt20 z3Ydvy9t)XHw8qFCd;mt8r$e?RQ%MiUF@}!oDGG#E6xxV z=z>11f!msSqbAZYnSvt}&J+QXZCU5b`0!gi_R}Z@Qq2d2Mwc z%9aWfp&x2UGbLDvtjGb*p>4O(#}UE+QhYmf0&Vc_Ay<~3V0zym%`Lk}-3MOz<%)%#Pl z<=OjGrvuBq318+CJ-{30QA1-O@<-O!-zFNM^&wp}iWGG$B&eIYtF)Rs4;5FK=>Aa9 zyTJdUgpK$di~MI|ZC=Vkd^V6T5h^z))sl~Dq7~stg?&l_LW6N1>0nX=aS46Ks+vj7 zr#P2~h=M-LLX2!W_k&dv^Tm2}o9vK&uKMDMmPkEcj7~C78vw2XJx^s8uo(Lw>9ET2 zzXG^MDxZzwh4y=Hs@h^Y2$ntYP+GSm>#cM9ZiUR^>tiFtIol3wi8=y~L2f@Bun;{B zr@yZMir9Ur@yw@7ni+Jd*Oc9hFx zK$M%P9+XKj>`spPB?k6^h1pok(_k*E$fr(SnXlXEnE{ODRWuWqB2u+8*2z?-wl+WC zntSCtFwpr0nF!avN+7`^Pt@XDvec7%ipuHYXg%5TXDAXv;U-33A(vzDB8V%0%j-R@ zk!2mox%%pJ<_M$o0lf*YButy@IP%9Zz=UDDlr|NuSNW*bYB{&18Xj|$eVP~(lx>y3 zgjJh3l1)5_uw6CTgk`ABQVoCHT$nbFS*edKLAbhRxLyzMI-{#6H!q_O@+mM7#~@Kw zWFDq#m<+NGVr`grM*Mh=Dq@8Tzl-$WKFWsWruYa^v`B30wDORai8q&__SDBzc?K#o z^UN`hN&IN;bep+mS1Z}i#zurS+Vl`B&+6`B#XK@l^8+&2+e@&zII(kdzid}Lm^AE5 zqjZ+3N*0O?1%{glymHcUP?g3vB#mH9MA)__>pUakjX+4jPuRS$9mmbImM8^= zOGMzKSY0_htZs;&-)|di4DJjSjVQ}hf2vq`u?G4@2@M(y#8xp{#1&$)ZW$rlUwG%{ z-S3I$D5~^(7stnQ#qh(0D6TnSA5R2*0u@x*22u1y%V5wYfW$b@)H*9X9{5!1Gw0`$ z4^fR@T%cw74(zCoPNP98@iS+WaFoE>g!a7#s-iwfRHKJSou%<97*I%619(655MjTr z6;k$p>T1-|cb9V=`;0i>gjBf%t=3jn_oC874-1o3(J|G-g$c?a=wn!m?U?CAd4WKW zm>=k4ApUHFtra|}Wl_G|#Y@n(Qv*q-frfU@rg{K1dLr%5(jA(Als7lSt8bue+zbab zVF0VKb`8x4k`2s^D1=P<^mk&LXhA!1jsr46^sGC@bsZfT)hZq4gnT+I+aHp`_XRE{ zDgx9ExOOSGF^DuVB_iQ8s$S{7agA7rKLtYG0nVl0q1kdJPQ3g#tw9qL?gP!_e~V$R z7B*H7J0{kp*t0|SM#+|$l6`>>9*GXki2@B!1?#&`s}t$D9D05bdTLaq__DzJ3hhhx z4>Z*xjuhGkL>lPDr8KhXi~8N*3~eqgebLTG`3g)&9`ESMo4O`ywJ{RymGvLXG}!Y?yAZ!5^Y19ukC`n~3GM7)2v! zx|C7WvVV`|+~>K~FRJPdp3VTPY##;_7#_^stFuo>5ewhPn5=@ApsXs_<27I&gPv>g~?s5SHzci&*$xeFVsI6?MsNJwojSpg9-+xbDwNanO9CUPbs06^E~@ zW3}{)@boKx;MgISD4?gb;X2~Nzv6Vu z_d;=oiM*wq!ou(NN8Zrg1ZYYlE==ylKlarfHe9u21xL{BI8t!pRC1^0=DGRrV0_Q@ zC#L85xcROt(T$6-@Y|KI-@7cgFD>WF?-)WG5jRleK;pn&=Rb9nZ+_@Mx-Fk~VSb{E zq@Ay=ub)@s&Mz*$+FSlG0WrrMKZI+3YuZ5k`RZGGO+r;}6mJy$DM;>AadvNZ=5yf|1r(je z0NIXNIS||Cv*MHEs{?>y+_cZmakNb+;cq-QqDcP%tMf{NmoE%a zN}Y33Vukiwxzm0dhmNsZQ>TsfYfZ-XZJv?ZTQ(=j1nt6FMd#;_K1oqQ{yq$GC6%)U zZU3B>;dh0p{DE?0kaj|iKj8?vvgC|-pv7<_WZBV7+B?`x+~3_las0^52<3d}UOOFD z7O7yf($skvy4y{NCq)B!Z=x|~NnJN+V(IV6LPL~?ORfvDDj*}q67_9}bTd~ci zlKmqOV)pG2tgWwY4Xr65@I8rddMwBV71bVAeGxT?v8-f6l9tsu9MFYr4r+BQr%mT; zO=G1)NW}SP4_kI0273Ew)qtwOwo=X-`1?bJ^>I^-9FXhSX17W>;{G^F+<9U(<%-*JPc!x>jH zSpfzK?Tx3%`#8Qlql2)Lf)TAiKHBQ5IOieg6~2NY7g@9IFI!7$DETtUG^srTsi2YS zc$`cq59-bK0{Yv})|#O4%XrxCkS29A6q~iTWNRlF;SlDMr$~v5hgerQQg_UB>M>2% zI6J+NtM*`(N7ghI_emz^lYyF_O8LW&&6oX-gU1h39L7r@8tpHA@>FGx*W=fR6E@q@ zg{!zJeVuJaQCuA=1@IE7|3##J$1oumJ5vky^UJEjKU#$)KuHS7B;vs(wJ%$?>4zlr z<=b*ca@HsJ!Osy3xBOqrn__D7pqhw2^7;n0$R~Z;twx??hrssk#C1cMtRHfFzhTG1 zE{;!Tmiq;ZD9#2W4(M?+!*~v>l$%5;__SINKTNAEIBf46X8185dhp4TD9_K#gp?em zl9d>E%I2x(q#pB8rt!89i!Mi7sMMmaZ?N?eM2!JHoQ{QdAoSm@`@TtaEkw{)WuZe^ zzrVO3sL=ewi4YYv1t!gfQ_Xo()Is9PQtqh!#?v&Mscaiz6wb$F>GjZE1xw7d5)*24 zu~!(MAawsNH*G-kU-c=3l(?|JJl0^q#LV(WKmSHC=#5YKstmI(V=6c4>73kKDwk3F zD!sjK#(*WYb8j>uP??1gq4SEU63;>Pk_#yOYu7(GAy4!ABPQY-WoeY1I=l2&k9RM( z;&F-Ki}KoHAb;HXNP-^_3u`-L$+~dmP7LmypyE23q+IsyIAyGbu{1T^)Y7+m(;oN@;N26N#9X<& zwqI@>wi=7v)<%`#h|WWx1pPuT%3Hx zTmHj4u@(m6TMc`y;_9#P8As?uJeu-!|Lgzd>}uWMUo5{kA<)1ndxs@UZR32fT6pJHGaO!4QH(eAa5+t zS1N59EQ1r6i z<(E$QmAL~w+VkGpLI9*Hnm0tLT@_hjW9JWQXev%DVG3YZJ@}x78{*jc{asC?1L_)h zF^DC#%H`1`O_VrpaQ}@~&1zbs5~&ja^i#ZVXwP!}j8mnEV@;<{Ahw)4%S3LKNFJ3i zaiK4p7j50(Gg`7o7JU5p$cw9Ok3@$*lZ@g;nFZi|2gmE)4`U4Rnm2m{vKk-zbX%kA zCoK32`kIhZtyUTzRW&2mT0PG|s|zU{4QPllcC91scP>F97ZXap<9Bv#F$2P|qk;b&2$rxv~0fH76P8hs?SUZLs6n%pW)x z{94NZ^zuBrMOvmx1jBKr7I^C(e7yj;&kgD*7xRHBhV0n=;gNznW(J%ArEdQ3v2RnW zr(kstOqa&TJ`*F&kJM}we0``YRAQ>!`T?;}wzZgRk(fa^)#2*9%Z+psyrobKU%nac znGGN&)Npn`s=}e$R4yL6IsRDDSF=Ps)Z;1?NH}K#C*jVV4dx0@(DMhJqOL*I6)&L4 z9cLFcW!bbaiw~-ib4#2tjht6tOE}{zD6zU{xlC2$ zI>jGRD=rdrA25&Qq4jqQAhS4A^TEeuR}+ZLmIn&KRN3!3YkB-ej*-b9-c-AE)S%N> zf?x6evrm$2MOQ(b0-<^gvSC_6oBe@p+i`Ajxy1G91_dbm9z>* z`v6e3>~L1a-C*c2`$0^HXjr4(?IN{jFy+;}uvyb!LNh16HAJ)d@63e8GRMmWrMZ&F zv_aLU&4#ktx$@=QM^zZSdGAFn^&JpWIEc06k(WFQd*!&PpmY;wf3>)TvXQM+vqd#z zyU8VT;5@(~T!27u_1N3Z<{-f&SNd-M>^C*BK>cKP5&U7*KXmq@FP2FiN4aT+-1iF~ zfRiPbO{*ky%`uehvD+s~XnH7V{jvXcN8((ts-<3M-#N&I$MX3xlZ!UGg+fiN+}`r5 zkj3AjM%Sj6BRHE5?Q@(GmaEXx+0)r!TPtcgyrsy<^`_Wc*hwyr-;OCdQ4#vF=h5Xj!r_#p6O*Q* z)GM*S@GP^XHnavtL<^TD>&W%F)LS4nt}T73^w2{aE8S?2vByR~WOdM+N!yff<@?z8 zI#ww-Zu3B+Dw2VJIAV7nOX9!ujfO>l`;d|vXtw#0QXN#ak`$I0n8kN5(2;87J-CD? zHmL*sL>eCfe*GTXwvDI2D~K%nI37JKu}-!Po8ExO7L8{#pw*RuB`6KEDkQxqNdG4R zbz*yTL(6Iv2z+#WI#BgSE1!LJckdfI7H#~xxtSQ;JHtJbofI^}g8L7|Kn}2;V?6dd zK9bChE}t-w#v@|YYe!RB4PsH{@hW+RWHlR3f&YL23-N7 zB={^p7mTZ^ud}HaFV%4UvxHK!)luf%KBVaoi+}5rSQwa@bCw;vYHCGARWld==<7kL z=59v02kEeG3Rm_z)Zc3=MXmaA)I9-9T+O+St{6L3)`@2_41VCAA&8E3bj5sZx5x4s zmtI{uQpw=7HHzdjnUy|za5p(fC=*%NXWhuB(Dh_u6(6Y_e%!8tO&OI$^_@sEYZMc) z<_`+vf$U0(c!m5aMnvIZvM^uI5SEj)Z(;;xrCT_CmpZM4!RQ9UsISG;<-MiaiPA(v1+;q7waq z#DaO&yeXX-esRlYcP9QBezojM(;1VYYslzFHa5kqnhTql9tB)(1PR83ymJM)zr}u2 zA!bL-PF~HWs6_&|a2T`59w8gMCgzI0ZUSUfQfl;Ojkd&KMV<)NhcnfxuOH2mUXuwQ zAM*!OvW!{`MXjm7TIXfL-k+n%0dP~x1% zi$3~@96_CUQxT;Gzf^B~3kR0u=7eg2I4Fgw5M>k5m~x;XrP_^xUNLYFvz1}cRTX7r z0lHVaPz&tCq!B@(_+nwtq0RK$#IV+@P;sE{>RX8Bn-rrhrkj}46K*PBvhLdC@?i7h zJjx#Hk>f+3F<_Y0nGofcP^IE@)+(L~Q4*1fl-B_6231_D^dqI(^dhIc= z=LA*Dx+nYb(z7F472oY=W@o*6`ujtJZ|o#z!EAVr%)^Fux|HNxTtvhvDsp6UwTFwJ zM*F1zvWTTAmTD7v5DPy;dkkH$be+d!3z!mh9?~B zP;G9Vwc=}F40A(Sds~L)9PeFHO$%36su`>ADF4lttX|1!{}kJEkmfex*_yNVfSVdD*&UI|G|lX40rxwlAPgKpuk`23wH2sCfRuKK%fnp1R#=<@<9%+; zML4y^o|%u9_V0m5cLefgy9n<{uobfvYeu+aZKo0Ktc|gWw&pasMBNnfI2UHbKn{9O z)8)imqR}+@&r{T;xui0wrvTi{YW)CT-RWebe0G8{202Acf|Llgnqf=$=%XtXfK4Qv z=zT1j1nI9*CySKsm0?}}<#3SfXM2MsnAkgZs>SG?0o-+s-LK%L80d)#K;3u!6;8=5 zX@g4Fm=G<8m!gGW=R{0399feKC9Xe6!If(%Vf-@0mQ7tBX0NzqmY|9qPu^277yohID3?W6U;XA5NfW2T%outqW~PhQ+n&nro#DcM$Z$THW`N zvNBz|DwU7qm-tFK?Q`5dA&PTB@?7}m0eDq==POEw^{A`Fa?qK z&48UqJjKg|to+>?O{Xf0(K=JOzIa?8#vDp}6Rf^uG9;_RQ>Sv54OQdMjViE9g742S zMhS8Ye+*}NihDGfGuOzbNvx`CgC7KR%vHu{O-ehz$6LT4Mk3SiWVM?^5C{rNs<(ci zqw`nSS8I-1*=qA%mSmm%)UgQ`dsW)FynP!Cpz`|ATE_}k?|*Q37_<7=60FiHwB(_h zw5+MMx={v+RgSy*%jLa^{Rki@+7`oxIZt}@^zY`)n@lMhgAPv!!2u;Sa^;2L@?^x z%A-Mrjx%teimuzTAPSO;F~lr&gy>_G4IY{^P*NEOF|%r&ntw4|Ix}Z6Za4>|Vq}%A z6pcxIPQ@tDsnqjX?bEekhr8)RQoOi)#Gg%k8s-M;;psx6&rT16qf|d(x zQm|i=dq2&*4+`a7Tfs#LSH|);MEHt+!b{0d7;B0PK<1QGH_ynoq!E*2hGkz#6O9hV z?$@wob1i#9kmr+^>ORB=Br!O}1{@=Or zo%h~IPq;QRxJrZG=B=N=LCa3_ths#xboN?(E~BHD0#-A0HRWBd% zQcIeW%y@>zZ8l81ks#C7e+hpvP3-w#+7K8!Z#+falSF*kz#{e>Br}RGNxX7AU1lVi zBM!bs|1pEQkrg!e8V!3s{|$r6OO-b5{0em=IHTj>B%>xTM{2fQAz|zH#Py4>+?xni_0O!81gn!QL~C|A^iO>kV^4a_%tZvJM}($5)k4nG z1`n!DqAq7NrQbVbxd2VW=*}I~?A_RaioH~%?eBYLjJ5@FW1Pu+UAm(%H!%U>%pk7} zejlDzFG%i?NWK}?hzUWsKEW}sW!hRv85emvYXb>bj9PjkEJUSs#y-}~vu{`L=EN&3c~hF@`6?yd zt*{wD)SEe5tJzqXKE$Yy+1IchWywJgfw_Q4!wv!!5v&6E{)Mf7)=|Ty$5R8b@U^UT zH*#GGHSYPR@bGZ$75&;Bj!Dh8Z%`1MNltRwF(-lxD(>)-*7(HhmG5nQ+i+Z`;k`|g z%h9)2??XolklwMj)H3$J>HaS9heUSwj9nb|SnvxxR~23MWzjJ&wWNu0GHR|_`D@uU zJcWrzlRcU6ndDlgFI8Lbxu<+@@QxstO@yNH$yd+_nh{q=e4eP<==cK*H3z8Y(t_9COqt4~v_Qlm%pPjo%wZFKfn|@@9(-C_ zTK~A)tQ3f~*E*=hg0)-;lGt;ScvIjOMibwZ4x zJ_UAlwx$oR%6XV>upP2|637WYo24&Q}Y_fL*yf-Q)J=sU0Ln?t+}=J zO{6MCeh7$_?fo>?^zii23s=e9C&jWN+3Wk&N8il?$Rn1TVg8b_3$+-c4t1EpM3jNP1tx-~ZtZSw|kM3YHhY<3yn%Vn1xhDJu% z4Dv4H$I&nplNH^mY?|6wy=hopGrWsK{z&zWzg~2L(?_BXd*1qJV>321H#9~{E*{+K z!e9TFLZas6aujoB{o2~V*B17dvd{&Iqsk3=Epw1yoDK19=8B`6=j}^sM*D%B$mSlQ zX#nr4DX~ji#!=Nj_)ias_^{Y(lA?qcE`a>{=4^TOc?#56oiVbq2ANi8i&=TNn?&pk zt`VtbWh*T;WGoa9?%8a=={cj52ay?-Yi9r)62hP4b&xzbC(HecT>GQPlc<;0Z%*7x zZodr#pCg`OB3`dw!hrntXAoJmo=QMs$@kx$r(LhAPd=epl?(E@ zTyv?TwckxHOeIZy3=>WJv}?OuzDp~badvrF4_ zZAYU~d}%i=v{4M&=+*K|6X*V2+1Qvjc2Ko9YD}ENS~}lpu>xTCv^#n6e-9qt zhV_&E$RMR>%`RQ@$54%E!G$j!61RAW5b~GSPP)}#v)oupgLY4;dEuZK@1+Gg;XV}I$rIL*jyWr z%#b+Fa2-|41c5tm(GN?a8dVl1zFisqiPky)WPO?`%oSsK(Hf&IDaL(r`%S z-2Wn#BoRnHfqGV*!s*;zG-l;5+rkmw$u*-sA!lNdlNI=^8=bE^h^& zEODXG-PWduHouXLwjF4F!(35IXa!Q$a@o0)hwQe^4f(f-JAX*4-Cow;VDb*TZdS@H zqUd9T*+%su%e6L7M5t%M=UJ7V9HyWKQT0MWs3COo66`!uFnY3gmQjYiy2x8XhO@)> z$~WPw(}UW1aF~-s=CIaPH+8kG4exyi}ai$+h{shB*3W0rRF7=mD$#s zvR#Q@SDXD3D^=`Ph`BRQ^{vl_$cFGe&)d~zCy%|q@PdImLSty)@pAQ1>&enPc=}Hc zxK|095i`i|VQrKL0815&JK&dK9DdZJTv=}cxe}!(rRTVQA zz>Br`kSb^ePLUvOWki3xxKlM4deNqbyEV}je3vb|B;s5&FGql9?_#CDoYdH0y-F&x zmmEfNh6h@>F{QJ{ho4NR2lD=9hGNH2oIC_rb$IML zpQS^1(_7Yop5+Vhy%+YHF|E`%=bc9rjv2?=;WM~G<|FyL6?u#%TieI6z;E_?35N=+ z0Ixo25mhW*iKUS!M5jj`B4Aoh4{hmH(BZwuOSArZaffRMr0bkL=(zyx)q{3nGIFCt zP?|CQYOzYk5rJl?01bIJjV$ahRJVSWd3!3Z>FXU+^up2{FBnzM>P|-;XGsVkL5`RF z^7=C zeC2+{=kIBc)0DD5`G_YoUabnci0OMA>;XphacRZ#+lS*D8?ARGW7fDCOLMwkx#)by zx#YDL*_I7FjrWyjTBGud;0GL)qpsT(*rB1J-_=`Uw&ydA;1-mYlcj^y@4#eC#Oae{ zJMzbmnKyLiYBU&+6!x)+AHU8|r(4I|5gXO|yvLXkB8XQ!H zX2baRkI_{jpLFvC2dRbFcD)-@6RwWk6)$7O2aHGPQ4w5Ljz{X^ANl66!{l)US^OWr z7AZob!By7dm7H-cRkSe7adHaySI*vu#vJk0AzD%0Oj~;1NL0@B4>hMui3vafOxJH( z4|j*!N321k^8ELv`Q|voWIy=68f3oF19ight;SN>tLXSx=j7MN<#sD^G zXN=O6OXa?}ym}R~{&5qmA3br7O-gH%p>*6pf0>seX8#r;TT_si#b~RwReA-by-m5@KaM)U^CF;34yDGKb(cEIZa6%3o05E4cb7* z+;9{Ba~%6OZ?QP*qY4Lw{;`lW{Fw2)eDG(3ZA~DV=!e=H;w!?-D#OdFS1(gG zyzFg7o63quNB{kdv#R(Yms~Bi4g9(oQwOYZYF`fcDwZ;-e&+u6T3W7QyfyOLH~hV{ zcv{U@RWmFQUhZo-NV~bPb^B)Ma;IYLenRx_^`LpLomh?w_P?t)9#vU4oFt$%US2J7 zG3u77_b6!)XWOBm!OJr?p02gOc^iVO`vx^92i{QobuWO~{!bcylk#?ZolipoAuKZr5iYfc{YDSBTuZQWm0!K#TmjNYXzrs)cQG&h zs{O^UW3-$Pb6!s4t@cgj;iXW3B7S7t=z3bJhFpwR45Ez8fI41>sx74>ekw!_IkXfy zaL5ml)#=(w-DYW8AfCLQ1e{;|xE}b|M;gTf5I`}KA*Be@mJHPc`IVnmN zKzM}j2YhkQ(rua?wS`rnM9N_)A*)+I#aruc65|6j1X`K72zoM*5Z~k)`YpJg5u#T# z1UnK~t?@aOUqv`d{*9m0_V4EBFisI{SFXLr&WLI~tQ zdF3Fs&^^1nyLsQF`roY8z^SLRWCE{Et)_#r$;h|s@RR6~(s*+?KO^%8-RISZ$H2>s zU{yd|BIT`kpIB5PjcsOqU)MkLBt+l-ru8wdyMpf~uKXlS!ZkG8fCc|ZBT$+q#M{LXUTT@!$(pFyi+Z!=WrIl!ht(fbk6;GJYVD*)Qw*}LClLT+2yS_;POgF zq9xDxnSU7MfAAHf5i3~pi3m+?P6Eyb=Wi3&phKKk`PYcAC-FI3!sn7~p9jc`Cj$Q8 zuHDipWtBYU8|yeb(Ipdt&#=;h?}Loqf`0}UBZ!p$r;RqQfsXP)&wO+4Vflp$K6?&Q z;twAQ9bh;;J&DQ?%~cJxeA4^Usg3;(?o`E|Mm8(tG|Ayr6JOM1hW!Z zqxD=krm74NT!{cb)MHL-r<17RXDy8XM(g;r)EeD?j?WYa&0OkUiQjcxzi13nL8K!H zeDiiC=kH~xEt7u3fCSK42D#NOh42IayWdgWtoKjlQnwdQM6un!^>Q};JNS3NxvanR zz__R3*d{xY)ysy%#g0*R>YHm?_pI#R?Qj044R??sFMD2~Kf4zvu{NBA_$usENKfTS z4Gaw@rs*oK9f_aLy@FV(2ZI);S8rim-Z8N3*Dz@+q80$8+CUpR`}czcAl9#Nm*w` z3|4wuio*VcAN5^%L%@{ESF$qq8bp%5q0YxJqK_}=U17JDLBB@&VnLzg8n{M7<51&(7bIU0jO&t zore{7s{$>&?z~!j{}cowSNOHUwt9R85(Umm&g{Vt?c}9`e7nV{JA^-{`()zWc}mP< z`6vz@TnCDyM`=+5RT8M76SsxK1reI)_I0bypU)^%KHehFfB%DUBrq5-5*yhuSmA{K zg;^?iEVP{?k%jiZ^P{_rUv90*a`V}0T|DlP7nH#NEk?)g@D!tQ88(Hzh=ZT!Ipr*U z`$%5ehv&a@uTgn1q`VV-gj@&HX?$b+@rmi(FbA5?fQfs@S1S0_0zft0jJDHE{%Koh zJ}Yt3x&j;YrLThxA1C?y%Im9L>9sWfg@~pxH)IpP6d7j^Rp84-`?w#;l8_>mLOU$b zsHSafe6DIKD~U7^dD|Fa5hAcEABzc6^Ktz%I<)h8d7rUL$;n|Or^b9< zreSTSTbv4S4e zb+4F~=Rivm>wW8;?bgzr-caIP$LEvo{?<~D?wb*f zZzmBM!r>(u$Kar};P##{zdSDu1fuBpt zTQBv*X8N3?HakuultkMtd4Q8C_V4LnBc ze2rw!s6?G6Uf98Phn-$ud5-UQXr(!yslCjt!C&F2N z42*250>QOtI?~TE?4s8%=3ts;Mezd=8L2BMI?lDT` zd+-%YaKTWgiUykY6;X$SH8WzJweL&qkIL~-{r2?12=un^tCjyE$j^eWlG=R)b31$4 zkO%>Vx<_(5UEW5hTP8D@Bgr(i{ZlwprU{UL2MxN=FqS}t>rLg&(9wFi5&|a?mrz&# zoRbHGs<#$=Op@a|-xV_Vm;kCqZ$2nWvjFWH`@0g7A6!LRVAWKP@LcmdKUJmGD^juJxC{MLX2GZvG;>X!!?68TZ^|$=XepiPnI_ zw7cM~+XO<*d*G+10HH=PNat07nZYlXwM@rPmO7qLXF!Qson(VS$82|Sra<}4PZMZ7c8b7fmPo~Zh5UZ z8?C7AAgO@JmB^Lw$JuK7FPee+iUh%!WLW-D7|TxUKs2)mc23L(zxnOpF{>7~e|-~t zbXysjma)vW3S8&i124Twu-3@uWC36HbFS0tID++G@BkdO@4}9WIp8^;aod!0VE$I4 z5;fO>p#q#OGeyM@^ah^>oA=vc>$sD!WAYKOo00&|IytaQ`xdy*D`N*(3eq_ZuzOw$ zIBQjakA4H}(SHCUoigxU#Jzd`lQpGIf8|7aJx@rPiiDYsd|b{%#vtYR4|TP4qD1Ui#tqq>Y+bmSmg z+z30qxeji#D!^@KHArVQG7@eAhbcu6u%r+A~fUC79DP7T;iz6qqP>aA;GauX-0lUmB1ZVAH z_OsO>oKgUmQ;vh}^my3zVKK~m?Sv9DSJi{!$pfW;*{indelQza2iBidfaQ!sAexo| zPK*$(r)0pcX@wB7vWcC5TJYAZW`DlNGS@ng&Z~hyBLySeI*x!{=iCE7!y4GTv>AMt zmVuXk1^f9L2wK_(A#2#*o0AMKbJJ1-)?5j{o7qg$W{F&hT>Bxi_OzG<&uGuwKfjIf z$8B($p21eRx!}LF0QN3t8K+Sl1g>acoYKfv&v!w}2zD;Lm^6TFX*IadD*~B*3&<8Iz)iOh_N{4x&{fS4xV()0>{SrXIL-de)42zC zT=V_D`JV&mh9hz%a_#%5IRC#BbG?4r5j;ncCegYJHs2kk*xSgs93s}2gYC39u$_8}eepBkHv2-_F}GWG%{AYX9!um( z774GGer*__v8MIZZRi0t{)o=TgM;mtgF{f1@A>Sz*Fx&rV%=tyvBa#2@k$NsUcfkLVHNCNR0SThtHEXFUGQ5}559VhEa7VgnO+;XOl8R) z%Wx(0a#?bB4$McCF=BOQNu+&*GB>nFO;-tl$tt@+bD%d&8R!Sg)$+h*Oc|`77zD05 z=fG#tCGgZOV8n^t5G*xc(g?vTo4GIKKD&%d**)j7>{Y)Q0*q_GcafZ(glY&jsRQqM z)!@Cj7`$|=A!5S=kQ&?p|CQIkb#@k5Pf7rLmK{rG+yvJdSHROK^H{-|CMw+`awT%@ zBWQ2>Wx)0DUyZXwKRL#4{2rn<7lEzz2@uW50;g%|u<6SquzBoJ5PTL4Zu7EX_mb-@ zfvaYuSP3C3Tfl2!IUHQq%CcF;D@!W5l`_f#vPDg>Tfd4+@?2)!WB*nO$4%~YO1av6 z|HX`-3`$wndx0f!=eQ=RDFbDU<8}*PQf5q6@yebw(48^63up|Kz{1zkz~Y^H*g5$u ztp3awJmzJAXjTqe?pLw{ui~l#b}z)Ge=+P?S`TjX3&C;5ZT98Z7uKs|%l{TQAW*QA zQ3{?5%D|nyrS`97ZxzETkSr(!kA;`ObzTN+85<27zl>zr@nNvlJPndr*BOalJbldW zu6yaFmM`e$BoKNp?wt8yTI}ZU_T=vV6@1xJ-`n6Sm`~adn_P~fyN+s9%uO*1JRQwsS zy2CV;K){ZzwL=TRdSV_|>*_e|G@89Q9&<}rdS3$v);7U@(+ZF+$p?GQR9N%L0dSh0 z4i*|mVaMbcu$dAM`_~jgqII+MPTY@kTN}S4J(fV|O~%z{ny00>v^pL$ZwolGwgY^% z8$dj*7|f>zGtxW@J2ayi+2+IMua3g{&%;@gbp!&J-GZ>yb&OL=S!PosuYp}vM#mDC8kv z={xzL#a84DIWH+YwACWibOs&j&=}|mlLzjGDJs6O;`J-A>x(9^(`HL|ta0Y3WG?Dr4Y$zkNVR1QH)TfuKp4eVoC>%nyj zmd!RpuyGR{SXU3nEf_IRJqs2SPO_651J;w0!C`tTh-RmOn?Wkei0?p>umO%+)p+L} zRT#9^|D-}UE`h*b)D(8Sm*HPyeqc>Wc+`d_aQ?g*Hmg^{mJjd3?!|Xt-w>+`8rkakE=YB&z+1l(r1Pu5XUQGz-?bWl8CI%Y<5uLF1N{Uq z^+f2X9JJI?J;Y_Ls7=fnbQG-LYhugy3t&GbnH^+2OSN-BGQWhqL9isEhGn1C?29rY zHDsi^t_^}$H$a4W3xus}VSjFffK_tvSyT?eYpPkwUkSbjmF%Qd!#?(Nht`*a``k>h zo0I`A)3aF?n+|3Z!eFP?aR^va0It(2!SS~famu?$wP99*>Tv!5>mAH8~(xn2clZT5LzmBLKbNSHi8lK4_j##EKS?8yVYQS@cx z8UtI@8(BJk58QM!VB7c@Muu6O*MO&P8OuPM*&BjouZD8i%ib`7#?`Qwy-oHQGcsMt zvRn3630P6XveibAu~hwlNjvx%RKf10g>Z093&d_G9T$tvD*Eta`X zRSAG)ujj(Hj|xFF?+kd(y9{o#&w+Se9(XLg12QAbLTe#JAO|n@wg@s|>HNkPh}iHQ z_%APmgY3kFnKi=E9c>V{z6rb+-G{I>55U{75JJ|<*$FIV+3g*$7=Ik>7`g5oe+F#7 zP2)5YYwZ}=FDQi_U)%+UcOHOX=zS2pQ4YIjH^I?O3fQ+)9(ygaV=3L-1VYc?{^iCm z4sE+B+h=k+9B1z>`!F1|RS$si>-lUMUceHwIWJ|MP(pmNnGffMmQ*Fhmh6v5VEQX{Fbt; zl##Fh@(M<}b=>MXbWH;U88t$vaT`cMaayu1HPo zl;i_Y(DA`h$D1ypD{me?wBar+dp{B;4R8k?)o{=q6wi{NYA{i|3zowhz;0v{h{v{q zNcSQLXU4tDCu%@Zl}3 zj3XLguW==W7`HI;t>@}peU=t;yc1^H0=v|NatLE2(x0wA(h~} z^ghQIK`ZMZa2fk`c|H4mEd;V|-RlcWEtq zTQozcNi9Tfd;k#}+Zftm?{Yb(vmW3269lfR1liJ32wqbLksBT`(yd`{mPR47L&PmDOIx~kY4K6{@vN{ld!#?}nA7SgTa`sj%0+ZM8 zv5R;X=BUPij>Ic;2MIby!)824qAEbuy95) zXulzaZ(g;5X#)dU*6POX(M(qjWzT0NtWqmvxB*+$tHI{I1_(541vlL+u+%&TYrYJE z9TVfhW7ZXLoR$vTzfS!B*?SM5s+P4~ch_HMF9RwFm=o$+>e6KnC?YvXFs-%se{Q|^8|^-)>fZYAxqsSwuQ0o+Yfi=-a{^;_ zzx}*lf87HKx_3})+mEaxy~wugWzd#r^on$%pY&u5`8Gqypkuj5N0DaSPa;Y#S^Fi+ z3W(HviA*zY)h9un-fI%^cPKeNgb=yTo&?n%xj+5di@w0EAg7f*2vfNMpS>60E7^iX zy+@2*Q}l;%+GZT5k4+-O^gSZ!c!AXz@~jB$P5an|NHuwl)7BqQ;xNrHpL;F!P%m-EKEeG>UE;$`*4-3ZLLnd!@JcCukz}DunxbU;%kiV zJrSwhQWdXz1N(o7VFJ42I}Z|69|kj9zjMMadd@9AlAVdHW7I5Bq5#jQ;5vzFvr_8vpA`z&0FY+u$3CaeLZSfvC zM+n^P`;nmEjU;aI(UCzC(>|PW7-7yh!;G8c8ep;3Q)Z(`IsA4qT(8UgPrua?q|{&@ zEPJzui@nAkxJm!;019nB(8w`BLfOZH&m5t0G1e^l=Sxpa;jH5*&e}|o;0_V3zDJek zr*9XIaKF@PjD+_Uk~JU0N8$=R_B7-8)+z)@cfeb=0rC59BSEVVfg2{^vT%&Z^&u?h z_rQq%J~ZcCgx1_3QKS1hD116WILSaY)RFX8mpVcL8iCy&Xia+-`atxth&? zLFD=dCxl1fw7eUM>YS~A1#bc+FR6NjD7C?PcO6`I)xr9w5+v)~NB+?lNIpp7YSNEF z>v0qxpC)Y>L8{?<6rC7D43RIFZIo@^hg>4md`nJDhnX8rHtgYC^JI+v)1VqB2>j`{ zUV^sW7YJ5t4T{majRGznLiV2{(cEK$EEJG__#LuLhfwS|fl?CM94q?S;w{dc7-6sH zSq{?$A0#2}qvLN-e1Z!T+(v{-7yPBJ!%wOe-qM%p%V{JPMZ|U%_c%FB}&1 z!&2}S)ovOkTUl~2w+}6sHYPqZl15c8HghRS0=wfoPaIxf27kF5aFQtPED3q+@nP@_ zZz(OW^6I})uUGY``0cAb=PFy;>Lq^;G6Eq)roOCC{q$!$Y@gwdT{C=1SVO39xwE?K zJ3mITTtC$3?}P#WHI{;9E8Gje??;F#2a#ra2Y!1m!$GtHZW8BN*e^)tCQfXtK@sUf z?vXdhGJlJ_W1NQcp}=+sXNgYpkB%YFx}P*=l3)_jb_wjZZ$N84(g zeir%D@2#{(KqSv{pdjf`H;p<2$h90~IA7^Lg?y_K78c;dw8V7`7kqv}h5HzaY)4S- zJwc<-2x`5)&?xl*70#nLZP88k|1KQ2*O9n(z-`ZE1S+&3P^lRyMo*EhF$K?6LvUKq zha-Y7a9H3W^yjs+g$~lQQdoFEj6{~Zn*z58f*Vc6W^f~}2lg$>#esDxY&~)QVFMU9k!Jcgg~lo1wBajQWi$392o&(IXdQEtOh%osZ$TfdLBHDu@>j@S|AHz%Z3cU8Tv8Avl74E}BvL2_bA0tU?5Z-GCVK4lS z<-D5AzXP3l%~0hlCrXW`8p|qYSGf4kZW?j9y&JioxkkXnizMdx!E*CyBp-N)Gp?^A zZeD!D+uD#<|FCte|I@6qUQdD(_TMK_y#oF9ao9P-8(U{Mv)!Y(y7kXa*!mqOpeOPD z|2XjN_)I?*ca@qE#~dSDDnGjfM*I(PRIrBtXb2}3_9I?-nDpQ|eB~~|RxA%T+ltww zwVP-o{KRg+Pr4aJR^2GJ??WNcYNmM)k?R1m&H9mVJ&e4gBLrikD03yva2`YcF><&D z1Cv$WlTLs7qm|ra{pQ8TCwel>-Xg)^InqqHT(nW-+r1-vA0)A*3*|C_QujfWoR~l% z;eIiVN;MwSM6W~0F@6oZ&6V&LZ%3$n7d#|rgcGko-2NMgP<;*mpN8PIWD2%I-;$IK z`ENsgPA$u?6PpqCO+aUId3P~PV7XD2YXssmBA5Vk!FW*;+e2&f5vbZgcI0hVvHSDz z{s+IT;&nD&{iD>0v5)`KakftHnAnaI=uJ7&6J*Gz(snIYIY(~DJZ z5^L*s&P20b*h1%Uiv{*@uXE{FGXhztfCHPovvZ(5w~=7yCai^@!DZnPyw?vPQLmrv zC%|nd%B{e3qkiosO3$TlAyBp*sRwVP*zpxIEnlL{X#zE#pOJ4lOcXneT#F$R*Vm}< zqUScqv-e` z%ALkh>NJ2_mm#Fm4pGVv;3{4RFWEY>1aA>0{T^=1`*2v`4hic`m~LP;)3<2AAMZoPkykwxZa>TM)b#(Oq?z=XSGs)cDY6?wDOrDRLaV}M6a{uYD03ab zS*Ly?*g;ggllZ!gBGcd%0wiw1aVJ>^>1*(oYC?c)8&XZlQYiMqf898o7xt3{c>puA zA$oJ$**(9wbUB@qa8E2+*V)qoFmqqM66ueBR8kPIYW)P=W&4l8cYdx zP6+qIZOIT~l*W*5!rddQ8IGbAu-$nUo}$fg+1?E2?M;Z&xQDaWZ;@m14#f_`k~>HM<>tuO$W6mK!B&9|Blk=|5v9<=Z`&Q_LHdg;)2rysBoSjitRy-$0W`= zzQ;xXG31%NMyUK91WP=mFQW|}VvUGUe1I&=yGYW1i@?nja9lXRtcMX1tl|9YP@H`l zDtx6xsu}Dq3R1IU*`vaoEV3+F)Hpm@I6#gsm1-slZ5*5YQsB#F;R10Qouy`S?@5ID zrXr*oJ;p_sPZ4#2<35A0KMM0YDX;z(Yg68P18=3~Mw{)mIIuPg67zhqWrjT@=7g|# z>aLkS*iCgid+r5^*^zAWN_=J*#AXN5InL~L>A&5fWGBlZk0kdO%*d4s#c^3WYI7=K zA=pd8Is~VMJqTVuf<*2nfd{(~CVvY-vbR{ydVtJzSZ+LvK5*wvIt@fM zrS)12zn|peby!~gP23IO-lx??)*q4s74Ka3lx~6f>iTc_sk3~ja*zIyntKx4W;hYS zx>I{6H%EZ+(|0x`s6?@R0W2)QCbmdyxv&5ibL9k<>sR9B_&CAkZkr;{m(9eL+v%TM z@@gym9zGlTk;>f$>hKe|iPs}V;|)&iu7KOFD>$*`0wU#}A>ZN!F8B_k+IIkD!X z#@jN?pYuWh|J8CoA0kyA!)@ixBe)##5p8k5px*Bbs@#Xr;5+&^aeV-n-3{;*Yi3_e zIJa}o(RWBv8-nO2%L-zkIN?dw->U@4S=c(d< zbE)(CY+mI)-cxAbgEF^%BH1xC_>Un`^AY?cI^npj9$pen@Yr(&?oxHgws?%x{iE>v zVU$M5XE2$6m&IOn=3Rp3ybJ7$-a9Ls=rsT;^9sr4L@+DEG6-h)KxTFlqg!r87nl30 z$d~&qR4_Y*H5i#WTnbk*l=!o$;dwE-zjznR9Pr%J20t48(v0pRVgGBy z?3#k@qDMF;^csf*?!rKzlj?P-&M9Fc%84SEHo~nO;cN>RfBlvN8_DuqcQT=k$6lgS zZgPtwRT(~_T)r6Wq>)^7*0-ELMzgcSuwS?l#}+)Hzvm@RYP2I%qn6SpOp09e`%qBrIz;yW8DdnPBShv7+;%syow6boA0k=r2?~z&Ax35b zp=-Y2m|!eT)pMu zrPS9JqwhcR;<3E?53LWc_iXf0ZK^M_8cqw5y9w=udC(JRf%?2MYQu3jxS$15+SlMM zc^g{%wbbULAwJKKg#~ua@?=80W2P&1&T@z3oKULYh<59YZ^yTP=fWm>C8=+4E3&x0 z!Q36WzyIX`xk+Sh+fP0ICRhkQh2z3r_-=WJ48s9rnLLA=< z*Xeon?_J-%8WavQt2w2#+-t~gdjlNB>qsb%LvBtIOqSe)@?2{BWZ@k)JV2hs3wV*Z z%FRuNq<|k}_(R!b6_-*aKQ9HlXZuj~BC&PHZa#PHne9u|>I><45%k=Tfrb>{$-hBI z9Lv7pM3n;;4o=kOl|xsc9)|_)v$RNuMQ;!+(T7~iK6aOAZWpXj`CIUn?3nZxZFSR-cP2$@68=YsvI;D0{w>EiMRz{M;1C z^QU0zOnVa9lThSO!y(~j78)=Tyic~ukKUKWNLg!nDgu=*AzZ7mChJ&NTIac!3Oo_u z)xSs03vKn#Tov|SdATR-cAbIdl2m9c%76sF7c_*5p(AvWxh-{pBE%?UAp)8Qa(z6t( zFK}5lGP4ueq%W6KzL)xo`n*c$^IwB5|0UQ6_rQPkDAF`PpxkK)soLG}mZIa^N`mAB zoOp57Ut0;<)*}!l_d3W=>MDHpbi!5a0>ZT~Am<&-YN3?2! zc_hH!LI-klH{Fzp3Xg7_wS9}jYb%&w%JE0B39JK)>ZqMZ!brFi z@tUuYsPPth!sj4HA}S*gitT)MM5r!M6;6k&z)2{~r}jNJjE=ct*KBueo@vEGV%%hw zvcM_q;q#`?i(zvR9F(wyIOO!W%7q5B1kS-s_#Tc4y`cIEUh9UCa$pFjtRBEes;MpC zaEKRI{nam}m3uDYw)=8{pF}&Nw6CJfVG2<)18`qDf+Ki_%EeK8r*& zi>Ni7&2Dn3S5kbD*e6)Ph*f%SB#Wc&nc+{PaR|{Yjrt4oNnAr%I6#3vmCcMw&k2Vp zpFdRQXG29W8`|^F!FJJeSS+~@t@$-jqETI${}hpNGE{^zpeRUUyCfd=d&-b*dKcdE zHO(a_Z#a+iP4PsQSN~J>_SI+Goz?R%>a2==Z?mHm5o)(letZD+zT-&L?1RdJ6zt@4 zf&#TYZNVC-2^2zZUK}iz-XVAQ0`WSJVX(NK03Zf(LLnrm^|w|$_O$Ax?tj!%Y(Ic(-7oN1(+|f5BQ$EhgrQI?bOr07 zKED_W0?G9FZGTs8a!Yn@JPQ$Uiv?unMl-SHVpOX9IYg_WbSxH1H1caMEQF@eSrXP* zSgg7Ub-{cVCQzE6O3w>mBzOxJ3m+5J=F`ZYgS~T;sbL1N_bQSos|cq;RKN)`!hWz9 ztw6NyRm7XL3LyHa7E{OLx%q(k*zPb&vJys+#nL*a3bLdBHC~Lg0*qJQ0Cyci7qj2?qYTdl;;&< zztCkI7V3iif;Vtl@_sU8S3fVV`kP(jX@oid}rpkl^=$ z;krz?%9bNu_hv=vk_D(i($6Bi@7MZ`FV&`>O+>%bGZKWnzczOfk14TX^Wk6 z9NC`6asts%m>&z#dG6F+!yrD_2jYBwP!ddr)Vx5JJs>{k+oRs%3O4V+Wz=wcbnKkz z0mV5vP@Q)chlFpynuOI<@NQy|2ye;i@1~TPLnL6^+XD9`lVsOlkv+MEgY!F}KChgJ zw1_Nw9*JirON!=bRDFICTO1%sqqExl( zL1#qaB zpwd_Qy-l|o@r7!-x0u}?T3=BwJ-X7Gl~ zE+Nl!5M_2F(57>?@!1lM20?1RHzfJJAuZ@f?K23{0>KcQ=SkG+OFsu=>nt0hRewgV zoUn3X16lqU)*sXab69RTN3GmEg#v$8kB-0vUR?E$Qgj3^n;S2^+H+t*6AmqHf#}R& z$nvF-rHRD81vyZfpH8E1I;8nxAU->otW*inY(5EO0yU~2Xf7;(I-SSmx603tV|jku z`y}TDu+d#fD3MJLSS@}5GvSBO5I#ennMR~rMvc1wYQmW$tiI4(mJZd0Tzo4W@(aRP z)m)kdr9~&9x;Pe!ivw{&{4CsLOIyPYE*9Ua$mQeoRbv&2@yNfDd-ec4Q#~ z(YfxdjVlVpvQUBS+!!|D^=*#gB%4=I7tEQIm>m%$ClJI70sIk*fpBZk!9|yQSRj6O zDE0{!u~ZTz!8Ee+1vK&okSG#i&Iy2uP&zx#k*BIqCX3U`%!{P+a-g%Y90n`OS-J{m zmn7!;lkGYOvn4lRvGg9ah+GdYJI_*Jl!Y>&ESyXYof_c6R3g?;77mahN-$V`8ZyE@ zP+1ZM)umC;SWHyBA{oY;GGVki2FJznZ+fT~T^#5c<89FW2dRb8S5BC0Pq}wwQz5K( z6(RM&3)Fi~pe1Aq^+7|p6gGu(Uejz7=}M=sM6uIIQ0_*Z=M?IEh7qv0mBsWW1l?Kt zG+EKc#E^r5AhEYd)p?0P@t4%5v!NgqNzN&l2KxvoFNlZE@>48pU>6^^aKMd`ujm|4 z0)TXu_sT6IP^EsMFh3sqmy|(8Fat^g1Pp@N`EmjYJW>6lmu)k>L=@&F6sS?-(pqo^ za&r>N;uo=5PZ|C&i1P)q6)IdKQ(KS)**P)va}o;?=q;>d@l)+ZMNE9PmgKMr0JVi_ zEM@D+lKZe;{usK#)ht%ag%0!=*FtaU8K^Euh78#)xdnl27WdHFLZ}g~sxKyzT|ktv zG!Y65=x-46!GX0T=8Hn0yxg1JmDWl8Y-d5xRj&^NUuN+H=y$qgwWDvVyYjh4gCCN+ zjn`$tWm^*>Rqmn6VF;IfKjKRC2Q)>Dp&{TS>ioZ=<$+j37ZJ7+A!?Kp3P20wFFyVl5a0-Q@*rgBO+gS=cheu5H&$KVArcSN`83 z>m;&QApZWog`7afu!R8{3ksmWw2}q(rRS13F3g4e{8*w{YIt-GH<`szuh!yxYIq!x zCPIZoQ(|r)S+N`(THFH1HE*H2s1jNvw%ob%;j63u^vasu`!sft!D$d z%92PDSYH~@1DJp+2~%5NK$N?b+USyW?4IKcjYTA~i&LPoFqYmE!QeuAZusPGJ|An(yUL=us0oMYf+B4_PU0;%V1x53)o)ECowrNd`+>QC*l0MS&C|f=U>z zswF|qhV1-sXp`6)uc?9QifcHr>Mf3~d<0E8CdVJcLJ6FWGFV+mjg!bgAOLd0L<}NX zFyB}Pjpg(jk%r;gd?JVt9NkzAll4W=6-mXxwYgATMg+Yq5(j@shyMCdm~Tye5U6#& zrn%yQ8c&>l+qF4s+$37_RZW=kLnNpUB2lRqQL@hwEB6L@h65qrc#y z-zd&|d_twm2b{5*Mve0ql-m!Z;LrftB0l1j(QBBktA(_%7bN&SVY{IV#!FkEyQByw z)^_8R;d`X(z9Ru{hW7F_Cahxf+;QmpGdQrS0DA?)Aw}e>ydVxTf&l~#evn@n3Q7I| zBGz0ky=zipo?noTNIowFz$^d$VzusS5VzD%V{s-_g;QC|2^TsrTvC7iONm_5ptrmTh9YHbWy}5*r=h+e8*V?mhw~4;Fj#t?&W(YxU#2G!xsSYp%n1aXak3e+VOy^DtOeNewv*`)}@g+hrxJL5=?$dhT+Ee=SglC!iRb$c_RBOuYHd`t*CSwi7K$@&dNFR z90`i=5ib6SNVNx%k}r`c-_JxgOLqXp#|BaBI)LWzF*Jnrk+^FJ`I=GKzDHwIPuk5l1Fyy42fzcWckC%_MgSkbuBo$;xSy;_u}yC z258ec2bPz^YQt5?3x~7DtG_ZIN{hp&hT`a^D#$PPV|1#%A_6MQsBwRv4ZE#%B(gbB zrJt3T2E%mYX&l>93H8;1&{!FbeJdhi@?$QHf6T<8^~um#8w&fqIn8Y)uX(qc`8B3i z4Sbq)HD&B*(b0Dq*$3a?ockDZ4BsI^;T__n-y>S`4I)WYW2Ac!A@vNo2ZvDOGJw{Q zk7y)XZ9VxB&5_e+4E%~3x6i0N{uyOfUs31#85LF^Q13B~O1lX-h}L6|fCEdT;s$)X zjklq*q=?#JB?^wx?78kn$u+ab096`1t}qKBG+_sVX2cU z!g0JMtGx2}De^+m=0vVNN`i?nSXB!Bg9W~@+)~EuKNljq~=w5AAJD-#mUd2v-<`A1|Gs4q?m(pZ{?L#xVhaAg@(7bd`RT@#D9 zaJ^g zn+tGkTQO{QmB4s?9(Ak`=zkvz&D8<#GQ69D``?TU@&xXmQ*Tv$P)RlHKNF_>urW&W z2?C^^!hJ(O&X|8jOV}r5X!Q}LK1YJ=0Fo8@5hM4SYBy5U-l5iMoQQP-*Au>=BkmKf zM1IEQ@Xx6A{DiZ1lPIy7Mxpr>YFtN=r8SH?pHVu08cusIlid%3>e5J9ZM*{KZI5VR zFM#9r>nODyp*l{KS`2wQhYJU2uSg~^h=Kf~U=r3099W&(X1F1P7gyz#e{7Lk93f(` zvbf;z_vO%8LDaam0@{mDLt|+Q4A-7vL4QLU^);4c!+Fy)cbEvfK}{iydIFF1|Z6u-<3j?FU{w z_8(O5cf8%2*$3UWKF}kpf8?jrFyC|rMjK9n+x5sv^dedR zQzWdpFj$|0!y8XQ=lhf3wwXI2R>?%v?5BK$sdv!p39#N?2162N(@nW>5xopI(KhNl z!PvJl5cYd>o3B>A;N5EG?^uW4P0mesX^ODjQ`F@kb{;l6t6;vN0@mbayhUHZW7{jF zDSSb-%QQ}NHwWB1jKsbD2ormXB*g*5%l0Equ^UzPV`%W6MxFlN|-Sx;`}$6GM};UbCbC8TMM zvsGNal8+!eKMZ2?U7))rj%w1R#>%)LUa#hrUsZ7z>oPa_p{hrFX)c_1U4tG`sp^tw z99&%t`;E5{B-#t}bq&329QF{IuFr<;o-@#29|I@xY9^w=N>^Fz)pAQdG}i=?pyt4ET^6ji zR4{Qh`za4cx0K<;&N?FDWE|WON1q@1-by<2>h1PtTX|ym-#A${I`uCXv+o&Oi>2MP z-%|t+$xCn)y?|poO6fZ;fz9Si@DRHX@7*M#Y9nY4`2}Y!2av8jiZ}%>OQ0Ju(yx&y z*N1GaQMS_Ra?l5~M}K4?f%b&YXbR`{6PQBviND~i#YYsGOyHu|M-*E0quiknO+gdz zmT953Qb2=l1~gVA!gljj8t{{8;6IP-gCoc}{04SgFXPz8dX|Nvu`)K%Nv?($SLKyo zXE7AX7tvpxS75mIG#s~e;_wfpFkD+i4Z9saJKy5yh8D76#V}f13EgE}icA%Ze>j8v zt21D=qlC@)ANV02$9Ggwr)-AR_97hGkcI;r5@GTaS^OUpm{3}7D}d?dEVxQufF+5s zt>_t;Z_b0owp(gPexdg#`AHifnd@1ICGe&H1Gq?m<}UFX%I=WLZC!rlflyo-=jmFUA{|Rjo6S$fD8SU|( z(Gu|)&0)Xbf;W-t@vkU3LXSs(#s&AUIDPN~&O3fWD+zXx%1s)m^I`ZyHV%JZi4&V| zLw7|stVvL7oIau0b`b7jH|h1Pwg^SuT~>MJH&Rp=Cy4k?Z(M`3~z)2K$)UrHRN6AX)t&M}xk7;n&T?^w4r=Ynygv2!q zUecFgur3kiTe7f!eH8o^T41&{okTYd2i7N$Ko`POrU3!+?Qj++TH3~mb2n<1&eJ6MLWfDnID2O?X?8blYllXmSQmDF1`|t6uNjm~gZq!)Dj1 zI~MePSZ*#LN^!V@ zoMA+2u_X^4(nOgXGf5b0;iuS4RGI^4i5eKJkH-lyqSPHZ@Y&k{lT8`07cIewJykfV zc7su^?apEx-jqcIb()c}&CYVTN;JV$tOfQv>TrDLdANwS&}TP5XDt`MO@WjA+2)Sw zZY7>*{`+caSeL8G#<=Ilcb>-a-6brx>L$?wf7vb~$2{2Ys)ZwcudZU3ad;gKv^$y* zq1=lIsUcL^lEn|6LZ1EzQkBM#sxXWMxjw{6_aaa411>mC5upy@R_a%DBut|%mfNu9 zD=zwcMfC|1R`bs&F#JRU`vrA=M8GDasQ3PWQ-*J8u)YAJP093~o`S)O3fOMBf+IiH z;H2!k$qfBBLHRn9ybu7d{Pv6f%G{una{ZHjqVM3a?K;fY*TQaV3yy8R058c~FxhYh z2iK*+jI8~!?S&+u`Sd&!hCjwrhpnK;M7T+vN3c>m9nZ#bu_8KthU|ScTqLXEuUwC# zJ9FV7bAdW^Cj8_ZVX`@$Xtj*aD`V+e9JzAD>MM5@{&LsgE!z&;9W_K*<#3UzLzwD4 zmLF^UV+I$R=(dzh>*#qk$O{$x8+Bsr^S@LicN~q>ZmzQ1k$2BxOAZXzXTx2h6;9%f z@Q`eQuk1BAN>tJJl@I$p6*RaJ#cr!W@ZKlz6@QK}i9wXwki`%Dj7*}|Or=RA$n>$A zrZ9#a-4S+k!H%fUxSq_#TR-DU6p?GdN1XHeMB+-sYWf*@2S4Jh`4`kUf5171Pq-EL zugEfd!4{oZkhmMJ%Z0DZ6BeQ}`=KgdN2ErC*CTo5cU7FW4T+qTdtcxw`Vcl-8sRS1 z1(!XYj4+PxK8FMAl8GwoVYR)O1Tq&EM5vAuWw0d?^;Nh8N3m+SOPz!9rbH&9CnV0m zVmk?`LL;1{N@2IB2v$4u>3yf*y_e`$>=aIjmcxlUxWB>`mLuyS(+FqD^K|Syf|Rep zQ??l{;!W_A>x8p-13hnqx6Cyd(BERPE&&I=Pk5W=aXECTcanFjnZMN+w+1)(X_r@- z{gi|gyGm(ryNnQ(M|6#EP;G~oTr)ydZX;6jK927pXR$pW`s?H9JGp{rjb}u)*AS&N zh!nL^T=e{idjAhZt;2{E?M4QPY|7pdB*_mU-(Vb9LZ)#e@eA6MCU7nOE1FM!!X^K| zpvr-)ztt4-4}PNh1;s}`q4?-9%8yN=$>(R}m=2QbDIf=Q7H;D0u-ks6&286hUR;$| ze&?YAA_uKiNj)|{U4fhEb)wg59Q+{*MjLWS46ETof@dR^LjqUd0B}Az=+uX@i4AF|2pzljs)0iRjjg z&h?PKM4wv=f29_Ls9q<5y$%-=bPu^Y7LRolyNCe!E_(lCgztL@XNfxcyHa4aC$H;5 z)-#how5ZtZ?j0A&a&i)lNIBS#VC4sN%{$2z+(CqP7Y$N%aFed5L8^_# z!~+ytV7-&RAE^uQl)i#6h1Up?=|PU(6zY9GW$ zXbzepVx7jVl)sR;{){V;KeO!x&stBT(s~L-#*@f7Fo8-U)-DU<%HUFN)A$18uRa$-lTx$Tbn9(VB$SZ%Gw@ttJRcjhtLwAh&e7ikhr(E^xn z&W7>UIJipHAW-QtJY;L&qi}%;H49d|v*9CON4CBKmOIjkL@%@m;m>+}nsCrRzk-mtnW-9Erv|Bxt`!f^IMT zWFNBZ1e+bD_k1-jo$IbgqX5~PY$DBJPhD5B&zpdezA3)nyQp3)xS{W(T2}8Ue!A0Lt^y~uy6Bp| zAYpxp812`H*!L3Any(O|b{C#<%|x*`i1=?IT>S>z_SO)s()U1O9HMp&o-&u|x?Uz{ z(uEYQ5tjJRS^bKm)5uW%fJB*oB+3pTokTW$-w-bQeMEiW09*3f8a0g$I=3l=6Vkt+ z!fqOQhF_3pFom4`pV1oj7Ze(g;(E-#(rd$Q8RpM8caCgi z6A5btcfTw|s*~`^H<10mKpnM=I&dw#h+N%>YLAQO(uG5AyoM~0#xe}ta1&R=8uSU8%PLlQHO71L>r*eMr2lxP{k)m zJw)`X^B(b9eTY#VMxy2b;&flaTka}}NEb4U`U^V?#`TBaPyg;j_Vw+tb*abN)10Nw zcDT@W3{~lXi{vHt|A(qRK$O-~q#F&;HGhjlonE@0w-KaD!m4(gxr0c}E_f@}(?Hlj z-x=pD&e4EbN!PfUg%aXaxXoCm&>sH@S^GwjC`Z><<{P!9DU2iEU<{p!A8|YFXS794 z;a2+3XpR1gOM$=OywhJ$ZTAJGmYlGTB2#A!7d$6Xe0chPliw#^T$NXN<=-lPa!qnR z@(n#fO3g&8NhGkRVY54rMDRQUl^ftBUWz3BTVy%QsFqOYt-;Y-?nrjT`T0vU#VNINuu6vG}8m?wzUdxY~rBVKK#Z}$BjM3viU zJj0p${*12luehG{Gdk$J%RxV*C4i{a{xfP%d_?Ynzal|-5NFLlOkQ;R z%-af(S9s;$6_1rDGG9l4w8IIbY$XY4H4$hVLNy!Mv1pA>oRBz89k`x^wiw}B z&FmaknG)EEXORfrN4owK1S+(^Pw^t+^@&=Qn~9_@z(ejl32+zL+zxokUm)vRPn67A z+XiM~{S`aO`aVXHEp>MNaikC-rBTf@oj{h!AYyf&QhiRs{0uRA50Gm7xFA^PLREA5 z-QVo3X0Da=YWb>G*83?};iP&yBDFecKx=}xLIWbTJBik>Bh$Eti2fBa=^7**c#Zh| z-N-Q;M4a9W_{d*@A6@H{tE^d6FTCET7y30vhTm5(*7$7jK5_H zLhJtQ7@N(A?q zKKCAy44=SeNA|t5L7iUxJ)^&wUAJx&4{8dBkfyL+ZhINIB4lLc>pJ3iyJn(Vvm2@&Q>?(-p>%sxXEOm2tF%eMU#jXBH0V zNce*53IB?gkpGEhzptpWpGJ}C&u!($K5ygo5?tazv$qCEb|%7nM*^Ir3K2?{G;Cip3FUQ0xBg0Xh}5}CcAlt8 zyOmzMf|P@gNeEsbl%B`x+@WLFkYWB92}Grdy04LAI*hpeFOhv{0I_O)$TAv7n(;g2 zS`3j8KSP?~TN2erM6OQ|O=25O!t5k=mc+cGwKVv?*YjKb8-A^#TAzFWP=e9b!Wga2 znsk#}h^0X$PWuMjaQW;WN5Mk5F`c5NRgeH1NEk|Mv+p z4)+k1J}1F_LD#nf*~YJsV)y|5>gN%uOV{|oJ%p&X(sjH|M0*=~hewcaJc_2UDO_}) z!YS2BCaxJuACR~26G~0Kp!MVw?xg*UdpTTa;1_fz{(^I!Q)u@6OHYZ-&%C%Qukgx$ zXYp66F?WkDq{5BE&{(`mN%@zjcjl$S?SjBgeMtJh!jQ>!JxqyfeF0TF!*VszWtwaGSl zie%$kNH*$X0}^+Q@-2H2yZ;^vtOt;5)r&&AVH#B4Aj_u!3=o)e%fz(6yiC|mc ztyoI~&UM7jEIPx_<;ncnv4abYzh9qg7SGG0AAshzhCi?uW$-iz0%_(TL4EQR8GVqHLoH> zy`HG_D(oe55w3QH#Fd0X>l)GL6Qmt@h#=(#66F>mu)B!gPn2eG4e6$L$O1n=010&N zv8P0(kC0+?AE!xBGmLsrU^Rp?r%@Cf`G8`ZPbjgS###Gexec$q6)@c#54&A?u-lWB1G@KUHCLglh5E+9s;6G=psN&D|2LH`C4xa(qkpM>*1(hfdE zmI+-ygXajR!7Ib;ISKAF`v2c^*%FA-d`QImgs$~{oHBcfaE&(Pm_McW--DC%S-Q?Q zk!*0A1|crwatEmfeROSyQ1AW)o$H7}0vkR}wi@BUtqk z(n%n=i7{WLYD8*Zq0Zh#V)=rJNwUFRqOvNlhktyks%fOw(7$H76RgeuJ~e-;v1NM20C@U$Ym8)@&!yK93;P z^YB%yftOq*0u<_zr1cD0hn^QkX|>g)**C@4r#~^fd9hpO+0DKUAI2vCOeQG`5hUQv6&Is4Mj5r-G4ecDlROlM$-$A4X4LJ58b1a|&g4 zUvSQeNbC47$g>zm_K~;9HYZDL{t}soU*nAJ01`>4i>>;QbnrT|4nJVR606mTOrkh0 zmKmbj1YeaZL};}jN%s-`t}6)LcL{!q=iseS2`{BmBFgg1QTk0~;Rff63q89+tAk#6 zRmVI$(U|tqq9*pS-Gzi_HWw3LST&{gSQPu-52*Be<(FX6mK&|zQI%?V|4bo?VW!y~ zoH_msr!0vkEgm39tq$QTtwi>XNYd{jF{SHZ&`HF3i>}diqW%tqX&zq6+j@LSsFKKj2C9-!YFs5jZN^CwjL>}zM5s5AZS;hQ zwTrASQR|_bD71cwY|DEnuzXEoL&wb?lQ`ZbI(vtV!!J?dIEs=JA5i7+7ZTPlR6ioe zWR$3Fg2ZYNnoy^fP^N=u!E@YD&qAz5v_FfNNzYlFWU(J1|&c_j8ZhHnt4QU@PdI;M67@jAB=soTol@2_%>Y&`ufI_)H)O)Qly zT>T3D-#1yDG>qsrL7$!_)B9|H!IjXTaXfC!DEVuDtZSq*d~&3Kaa}aL1-kTj{f5W~F-f%m9kLmWbfSh*+ng`BMWL&TWxm96-M3 z1Sz;DcyNhA*}z3qhb#)|)P}61o)lJ*|2&cF7V1LxN!{+FPW=(h!9UP@htNfQ#{H{b zP!sf?l-nCLN57_HY$4BQ3Z;RwL@JYL4S9nyuN5Ng4I%L&j~P<0Q>3h)A=P0JNw&{$ z&yEzeWhbs$wjtGd5Q(-u^qmGMRG*NW13%xS(E7G@50T_F?QcX5h3NMjheV-EJDJ@O zV*jN3N}>*9$aEc(Vqd27IO0yWka}JxLVZDD`iP_^QXHNO$uj{nnO-~DPRE^;bV0t$ z0@CPx&bgNQ&7(EqHGQ6euE{D&{7K25e~C8DKHYHMj@l!oZ=}yA z61}jEn)9UE&(5JNa9R{_)mbL!byBl?s8S!IHS8k{X+IOeenExf5sFV9q1yI)eeNIk zPALDu3KaZ;QR+P}ty>u`!!or+WQ!`lRU|t+LayrsDoK$gIrJiv-Y@o^qfq`0DaEfT zf({K4B`L3(&~>z3+(%8wTQr{EqmcM5>I42N>4Ca)2e=>i1@|w1Phsv$v}$%~`)$+( zzmgm-tGzP6S!AmW^gNGpBI+z6xJ*)@?2V9aKTe;wfa}(zQtf&X`{xD;$&-mFZ=LC( zM>mSxSBNB^6Nx?{GA6+oVAY2_)jZvVjA)M7L{0b{ zo%13JJ!eoIxQ3eGHRvMW(Yd`LmHG<0n73%YctB)(2z~qq6bCGzJ?bs)+CC+s9ieOb zO3pjqbDVB2Q>gOi-1Pw|*pKLp{24C_e#AiHk0>~~H(Y6BR`RL}6#SZ?*O*V_IL(+! z{TD^OwuHQ+aGGiYcx~M}m$G)cLJv2q_pelG1#eqDCutZ92naJfON{F!YJPp#pQ0z4) z?M*4RBgpX>CuKPyQ)8TSWd)mTI}ELDAGG$pq;l!|l2T2uc}T=MMEeYhZ$b)fljk{2 z1U`p+w|S&GJx8%8h2Zo#1@wEas}XnY`{?&sB-;!jkq9%_;|1=KYUN^8rs@Tev=M3c zBhcE=b}q|A)MKP(pP|xslL&cC+SeMx*3lTbiX!hBQTMgyRwd-`y0VM5m_2mF(Ye!g zYKt+GQvHOs*gaCPTj;*Lht}{nbi|eE?=e;U zlX);v8Cg}J;8%?ln?ZHD-MEQKj#X=!&jPp|sfNh3J^Ced;U-BJ6nYye?B~`hBay=< z>WCog&%Z-c#1UGekI)%?EWV+gM6#`ndLU0VgA7u!Tv<<7jiSVFiHLAmh_cdeQwm=RXC6t& zU+lU{g!mX*B0Kh2V8YFJofSgN;DVIhfE3HJRgXXKa#u8YVdm8(7T1lf+$NV0h@ zeXQxK5jw_W$={ZGt;@04lYzG@^fb~aaFqHB|$*U?*@LPfU z8|@#8{f*iRzZL0w&2$+;ZP2=ezPhLlDZJ<|yp#f0Y2X}Mqu)S(?ErO=Cdnx_h8>|P zY#;UKj?jDk3z5hNv_%uiM7%_G$R_Q(i@I~KNa1nQ{WIhenPxhTN&zj42#`AllI)+z z2rv616niXFC{CgIsryK_A0%~aK&s;q%Kg?!Wlqq(FC-^gva|lLEFgnHlX3+tKr&klag0epy0QNmhin3jUnrG zP2p>#4Es@eb^-Zb6VMS!Hk{i=y?Td8caunS9gnqUw8tFDAVG5kg})b%(G>E%cnx%1 zqR=?{E$Sn`qtJLCO&4BE(|tXW5G%imvok30m?okk0uNZC*Onwtnqc(=_v{T)mFJM0 z+oL#7SsA!NA^JFy9iAb@W=KA}+;dHeX6cS&@}0C+Po>kM zk*-5a)F#RTh@gFVpn``YUZRA~fzP`&`jBo&`)H4QPsF-UukF!|hR=Tjts(Ew5xs*F zQvXGs({xVDXb9diHHMg!ys82PzXz218!f5=R!mHUMZS|1)|+tu(k_L;q*|liqMFoJ z=f%%xzp@K`ycr!ae?dpoPiT!erqK2idT)Fo;yp$cZCB*Ggs#{lv|f0Raw4GKtNWq= zn}T1VKKMInmn!y{MODB$DNdabCAU{`=*~T^Om3w*>Iqn{1ZOUjBh&%-DroMbbAeAju|Cc|}@2=j?_B&3ll=5#}W+X7NZ zS*O!}_v}YWl`hJDxsJ1>u(`PP0!`uU6JSJ{zY&cT=9l@-)Ad+GXY9T#u~HZI22B@t z>3V&U9BSv4w}*dyk?{O*ad_1#?5#qLNotpy2n2T;D-;ZSaz*%zqB$ z>RA-}Orb)(Bn2AIqu#%IB$G&-chz6|5&D?FqAlt(+B9Z#UOPlR&)A3WNP6JG6)y1X zpf%D&q_jaH{vyhFd^B)@NNrYz9B!O^AYpr!>zJ6zTtBH7<;teuT(rvbn39PoE;ywT z`Q>{}BhPhCUQaqRK*wB_^}*5{264x>k5np8J{hE^H`{576srLl6z*rL#*ldGvGmMl z5n&elEQ+^66{%w;b{#3qMC(3DLGVhcm%nY6ylo~OubR%kniPEfxw&YX0t{kH|f?J3_qa~ckG~#bWq=z!4)f%;rhV!qXi++bf3bD&c zxiy~OAVtd_uOp-|hltRIQRFcvrYLMMQ{*>`yAF?0;l(C41KPi=yQA zDd|a7&7e@4`{`It&yhl;cuVrIqteQi?au90Q!-l1#jYeLQlkz={K>V3@Aw}*-<$3>H*D0jhjY!V)mQ9z8#&Rlvy9e08tH5=MRPMMGpbAI{ zr`irtm~Rvnnqb?DZ0BiGuk%Q8d4dv8Qj%`-k{;mpDs}@a@S3LI4dB6wo3xMgysD;U z{Pwnu9?1?*kx0t6A#@#OzD(u=bc_k;FTFwg#T^v-&p>~TZYUSc=#Dp|>+&bGXx@{u zKQQa#54E)#lac~Zpg_TY50$|inpVv_Q>*3!p4|EweOLd22b!PIL+Y(2=m1R@KBDL9 zPo(bNqATtYr2(r%I`2vKy^*{nw=k7@Eh5u(Sb9qHJV+tBE+9`e2lhZwV$+D2b3G@C zEC*yHHplfJz63<(N!CQ*J}*$_wSilwdJy~PCZyA6CtCI+mB_V#4Y7%!a~zFC-UgHh z&Y>Y>19|S_XpZD@;C0lU+d+M}33U-BI@iylTnQY_kX$8qB2)*g(EHz^#*h77 znZzE+iU@2V%>^o672)O?y(~wQ>oO|~D(1N?kcu@Bnev$I91-9!GTcUpC|^hm)s0h~ za;y@M6>+ZO@mMZ~@%U?!^#Bs>dL&)IT?$OX9QxMKq+?7<5lhx0vwbQA&)x!e zNilP~SatA%OqgZ67*Oav30=e%YJykL5VcL@x`X!Ek7x`(94_@&TB{T&Q1DMcZMgYF zZP17Ldi4=1{Xd{9>Sxr29H2VHgx1K9XrV`S@GDdWZAoFLI%o+c{?kOp8$wP+9F{v7 zP@tml-gQ!PpX_rQZ>g77D4rf;MVo3jOkw$|7`5=~3d!_4o2+mOAxAYO4*#WIt3;xM zQUqf+tyqf&$)ED%R+=M|=71EmxW6^UaY*`Ib6t$c^&Lln#~doWwk3Cao3=?OMa_c* zoNvu>8xz%9;6JovXbovznZ@|&&jYrmd6tjK*4 zU78(Khs~l{y^Fin{kR|ZnjNyt`R< zdlO_k%%Iqloxq;px>c795^$^6bt}De4ctEU5Y52{NK^HrR=rL)f=Lv5O`-V$6ZNpZ zRK0#e`HL%1py2-uecGQ-=%Nqm+AhC`F8Tu+LibR4b{n-suEoC7Vh&U7zb-jUcHLs@ zJ~nRQu7C^*w|Taoi%#MZ;QXAz^)1}A?3Hjo{&WZOT;^nufX%eIbD+eVkFzM&g;yOr%5vLPp8FKi>_(Azx=-A;_;ntCWu;plNXpk|O~!8XJ!X-3rk_-;frz5*2iR#sV6pg_Sd6xG4&>h@@piI+S{aeOT4fozW5)2 z#GS%!&lNFUNhT%AD*)uUOd`j5nh3C8icdEzdt@Y)yj>wou+hI)706cPg&9aTuY8Nu>nS5DAFCd;*dG(w# zr`e5YYgNh+fC2>yekEuOTT`_}Zg%Imj#Ajaj0(SHBF28{HRWOx6WnzQ?^A7grGiBn zL5=uhIpQt!qFmYBrNDFMt39F0fE4>-Sr(i<2zVHPC%rf=Q0coRBwHS^Ecshb4aiCd zr+H1Tr*!;bWVso{RqHNo&t~1V>g{2j`cR{>s8vW+fdU1;PSmQ`PxM@QqfU1k94_}> zm$s+dR=r4fG$74xOnO^W9S3D~fZL}Y%TnLmubSpGfP8OKwXPE~rpjw#C0aj}@SY7< zcx07Hl}BH%pX?U@ST?@SRvGEI2C*&Fp6)||`+^J{q}V(k&UH6x`v6HY%ga|Zzzs+eRs|9MaKTx`lZlikqEY5R%}gn7?6;ktN*;b3zPA!(+?J|S$5`SJ5H+=g{nY-g5Mn~Jhr|m z@tjwcc&%s>tRLj%yUz`$+6@igv3<0Y=`dxEx44hEZ(GE$MQh!MT<2L_`nJ)W?rhje zw0^vkV*ji=%WbqST{WU*)0rz4?cZoE<`ptkpg@5F1qyzP_zyN4`RKUL%sc=9002ov JPDHLkV1myZcL)Fg literal 0 HcmV?d00001 diff --git a/frontend/src/assets/react.svg b/frontend/src/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/frontend/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/assets/vite.svg b/frontend/src/assets/vite.svg new file mode 100644 index 0000000..5101b67 --- /dev/null +++ b/frontend/src/assets/vite.svg @@ -0,0 +1 @@ +Vite diff --git a/frontend/src/components/layout/Layout.jsx b/frontend/src/components/layout/Layout.jsx new file mode 100644 index 0000000..36bb993 --- /dev/null +++ b/frontend/src/components/layout/Layout.jsx @@ -0,0 +1,160 @@ +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 ChangePasswordModal from '../ui/ChangePasswordModal'; + +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: '/certificates', icon: Users, label: 'Firmantes' }, + ], + }, + { + label: 'Sistema', + items: [ + { to: '/signature-style', icon: Settings, label: 'Configuración' }, + ], + }, +]; + +const topbarTitles = { + '/dashboard': 'Dashboard', + '/documents': 'Mis documentos', + '/certificates': 'Firmantes', + '/signature-style': 'Configuración', + '/signatures': 'Firmar ahora', +}; + +export default function Layout({ children }) { + const { user, logout } = useAuth(); + const navigate = useNavigate(); + const location = useLocation(); + const [mobileMenuOpen, setMobileMenuOpen] = useState(false); + const [showChangePassword, setShowChangePassword] = useState(false); + + useEffect(() => { + setMobileMenuOpen(false); + }, [location.pathname]); + + const handleLogout = async () => { + await logout(); + navigate('/login'); + }; + + const currentTitle = topbarTitles[location.pathname] || 'FirmaDoc'; + + return ( +
+ {showChangePassword && ( + setShowChangePassword(false)} /> + )} +
+ +
+ + FirmaDoc +
+
+ + {mobileMenuOpen && ( + +
+ +
+ + +
+ +
+
+
{currentTitle}
+
+ + +
+ +
+ {children} +
+ + ); +} diff --git a/frontend/src/components/ui/ChangePasswordModal.jsx b/frontend/src/components/ui/ChangePasswordModal.jsx new file mode 100644 index 0000000..bbc85f5 --- /dev/null +++ b/frontend/src/components/ui/ChangePasswordModal.jsx @@ -0,0 +1,104 @@ +import { useMemo, useState } from 'react'; +import toast from 'react-hot-toast'; +import { KeyRound } from 'lucide-react'; +import { changePassword } 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 ChangePasswordModal({ onClose }) { + const [form, setForm] = useState({ + currentPassword: '', + newPassword: '', + confirmPassword: '', + }); + const [saving, setSaving] = useState(false); + + const passwordChecks = useMemo(() => getPasswordChecks(form.newPassword), [form.newPassword]); + const isPasswordStrong = passwordChecks.every((check) => check.valid); + + const handleChange = (event) => { + const { name, value } = event.target; + setForm((currentForm) => ({ ...currentForm, [name]: value })); + }; + + const handleSubmit = async (event) => { + event.preventDefault(); + + if (!isPasswordStrong) { + toast.error('La nueva contraseña no cumple con los requisitos de seguridad'); + return; + } + + if (form.newPassword !== form.confirmPassword) { + toast.error('Las contraseñas nuevas no coinciden'); + return; + } + + setSaving(true); + try { + await changePassword({ + currentPassword: form.currentPassword, + newPassword: form.newPassword, + }); + toast.success('Te enviamos un correo para confirmar el cambio de contraseña'); + onClose(); + } catch (error) { + toast.error(error.response?.data?.error || 'No se pudo cambiar la contraseña'); + } finally { + setSaving(false); + } + }; + + return ( +
+
event.stopPropagation()}> +
+
+ + Cambiar contraseña +
+
+

+ Usa una contraseña segura. Debe incluir mayúscula, minúscula, número, carácter especial y mínimo 8 caracteres. +

+
+
+ + +
+
+ + +
+ {passwordChecks.map((check) => ( +
+ {check.valid ? '✓' : '•'} + {check.label} +
+ ))} +
+
+
+ + +
+
+ + +
+
+
+
+ ); +} diff --git a/frontend/src/components/ui/PasswordModal.jsx b/frontend/src/components/ui/PasswordModal.jsx new file mode 100644 index 0000000..4ee5911 --- /dev/null +++ b/frontend/src/components/ui/PasswordModal.jsx @@ -0,0 +1,48 @@ +import { useState, useEffect, useRef } from 'react'; +import { KeyRound, X } from 'lucide-react'; + +export default function PasswordModal({ title, description, onConfirm, onCancel }) { + const [password, setPassword] = useState(''); + const inputRef = useRef(); + + useEffect(() => { + setTimeout(() => inputRef.current?.focus(), 50); + }, []); + + const handleSubmit = (e) => { + e.preventDefault(); + onConfirm(password); + }; + + return ( +
+
e.stopPropagation()}> +
+
+ + {title || 'Contraseña requerida'} +
+ +
+ {description &&

{description}

} +
+
+ + setPassword(e.target.value)} + placeholder="Ingresa la contraseña..." + autoComplete="current-password" + /> +
+
+ + +
+
+
+
+ ); +} diff --git a/frontend/src/components/ui/PdfSignaturePlacer.jsx b/frontend/src/components/ui/PdfSignaturePlacer.jsx new file mode 100644 index 0000000..33ac38a --- /dev/null +++ b/frontend/src/components/ui/PdfSignaturePlacer.jsx @@ -0,0 +1,221 @@ +import { useState, useRef, useCallback } from 'react'; +import { Document, Page, pdfjs } from 'react-pdf'; +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 SIG_W = 200; +const SIG_H = 60; + +const getSignatureWidth = (style, signerName = '') => { + if (style !== 'qr') return SIG_W; + const normalizedName = signerName.trim(); + const calculatedWidth = 78 + (normalizedName.length * 4.9); + return Math.max(170, Math.min(270, Math.round(calculatedWidth))); +}; + +export default function PdfSignaturePlacer({ + pdfBlob, + signerName, + signatureStyle = 'classic', + qrPosition = 'left', + qrBackground = 'transparent', + qrBorder = 'none', + signatureImage = null, + onPlaced, +}) { + const [numPages, setNumPages] = useState(null); + const [currentPage, setCurrentPage] = useState(1); + const [sigBox, setSigBox] = useState(null); // { x, y } in container px + const containerRef = useRef(null); + const [pageSize, setPageSize] = useState(null); // rendered px size + const signatureWidth = getSignatureWidth(signatureStyle, signerName); + + const getCanvasMetrics = () => { + const container = containerRef.current; + const canvas = container?.querySelector('.react-pdf__Page__canvas'); + if (!container || !canvas) return null; + + const containerRect = container.getBoundingClientRect(); + const canvasRect = canvas.getBoundingClientRect(); + + return { + containerRect, + canvasRect, + offsetX: canvasRect.left - containerRect.left, + offsetY: canvasRect.top - containerRect.top, + width: canvasRect.width, + height: canvasRect.height, + }; + }; + + const handleDocLoad = ({ numPages }) => setNumPages(numPages); + + const handlePageLoad = (page) => { + setPageSize({ width: page.width, height: page.height }); + }; + + const handleContainerClick = (e) => { + const metrics = getCanvasMetrics(); + if (!metrics) return; + + const x = e.clientX - metrics.canvasRect.left; + const y = e.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); + + setSigBox({ + x: metrics.offsetX + clampedX, + y: metrics.offsetY + clampedY, + }); + }; + + const handleConfirm = () => { + if (!sigBox || !pageSize) return; + const metrics = getCanvasMetrics(); + if (!metrics) return; + + const relativeX = sigBox.x - metrics.offsetX; + const relativeY = sigBox.y - metrics.offsetY; + // PDF coordinates: origin bottom-left, Y inverted + const scaleX = pageSize.pdfWidth / metrics.width; + const scaleY = pageSize.pdfHeight / metrics.height; + const pdfX = relativeX * scaleX; + const pdfY = pageSize.pdfHeight - (relativeY + SIG_H) * scaleY; + onPlaced({ + page: currentPage - 1, + rect: { + x: Math.round(pdfX), + y: Math.round(pdfY), + width: Math.round(signatureWidth * scaleX), + height: Math.round(SIG_H * scaleY), + }, + }); + }; + + const previewDate = new Date().toLocaleDateString('es-EC'); + + const renderPreview = () => { + if (signatureStyle === 'qr') { + return ( +
+ {qrPosition === 'left' && ( +
+
+ QR +
+ )} +
+ {signerName} + Verificación digital +
+ {qrPosition === 'right' && ( +
+
+ QR +
+ )} +
+ ); + } + + if (signatureStyle === 'professional') { + return ( +
+
FIRMA DIGITAL
+
+ {signerName} + {previewDate} +
+
+ ); + } + + if (signatureStyle === 'image') { + return ( +
+
+ {signatureImage + ? firma + : Firma} +
+
+ {signerName} + Firma digital · {previewDate} +
+
+ ); + } + + return ( +
+ DS +
+ {signerName} + Firma digital · {previewDate} +
+
+ ); + }; + + return ( +
+
+ + Haz clic en el documento para colocar la firma + +
+ + Pág. {currentPage} / {numPages || '…'} + +
+
+ +
+ + setPageSize({ + width: p.width, height: p.height, + pdfWidth: p.originalWidth, pdfHeight: p.originalHeight, + })} + /> + + + {sigBox && ( +
e.stopPropagation()} + > + {signatureStyle === 'classic' &&
} + {renderPreview()} +
+ )} +
+ + {sigBox && ( +
+ + +
+ )} +
+ ); +} diff --git a/frontend/src/components/ui/PrivateRoute.jsx b/frontend/src/components/ui/PrivateRoute.jsx new file mode 100644 index 0000000..75a1f93 --- /dev/null +++ b/frontend/src/components/ui/PrivateRoute.jsx @@ -0,0 +1,9 @@ +import { Navigate } from 'react-router-dom'; +import { useAuth } from '../../contexts/AuthContext'; + +export default function PrivateRoute({ children }) { + const { user, loading } = useAuth(); + if (loading) return
; + if (!user) return ; + return children; +} diff --git a/frontend/src/components/ui/SignatureStylePicker.jsx b/frontend/src/components/ui/SignatureStylePicker.jsx new file mode 100644 index 0000000..4489baf --- /dev/null +++ b/frontend/src/components/ui/SignatureStylePicker.jsx @@ -0,0 +1,287 @@ +import { useRef } from 'react'; +import { QrCode, Briefcase, ImageIcon, Stamp, PanelsLeftBottom, PanelsRightBottom, CircleOff, Square } from 'lucide-react'; + +const getQrPreviewWidth = (name = '') => { + const extraWidth = Math.max(0, name.trim().length - 18) * 4; + return Math.min(320, 220 + extraWidth); +}; + +const STYLES = [ + { + id: 'classic', + label: 'Clásico', + icon: Stamp, + desc: 'Barra azul, insignia DS y campos de texto', + preview: ({ name, date }) => ( +
+
+
+
DS
+
+ {name} + Razon: Firmado digitalmente + Fecha: {date} +
+
+
Firma digital
+
+ ), + }, + { + id: 'qr', + label: 'Código QR', + icon: QrCode, + desc: 'QR de verificación con datos de la firma', + preview: ({ name, date, qrPosition = 'left', qrBackground = 'transparent', qrBorder = 'none' }) => ( +
+ {qrPosition === 'left' && ( + <> +
+ + {[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 ? : null; + }) + )} + + + + + Escanear +
+
+ + )} +
+ {name} + Firmado digitalmente + {date} +
+ {qrPosition === 'right' && ( + <> +
+
+ + {[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 ? : null; + }) + )} + + + + + Escanear +
+ + )} +
+ ), + }, + { + id: 'professional', + label: 'Profesional', + icon: Briefcase, + desc: 'Cabecera oscura formal con campos organizados', + preview: ({ name, date }) => ( +
+
FIRMA DIGITAL Firmado electrónicamente
+
{name}
+
+
+
Razon:Firmado digitalmente
+
Fecha:{date}
+
+
+ ), + }, + { + id: 'image', + label: 'Imagen', + icon: ImageIcon, + desc: 'Sube tu firma manuscrita o imagen personalizada', + preview: ({ name, date, image }) => ( +
+
+
+ {image + ? firma + : Tu firma aquí + } +
+
+
+ {name} + Firmado digitalmente + {date} +
+
+ ), + }, +]; + +export default function SignatureStylePicker({ + value, + onChange, + signatureImage, + onImageChange, + signerName, + qrPosition = 'left', + onQrPositionChange, + qrBackground = 'transparent', + onQrBackgroundChange, + 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 ( +
+ +
+ {STYLES.map(s => { + const Icon = s.icon; + const isActive = value === s.id; + return ( + + +
+
+ + +
+ + +
+ )} + + ); + })} +
+ + {value === 'image' && ( +
+ + {signatureImage && ( + + )} + Fondo transparente (PNG) recomendado para mejor resultado + +
+ )} +
+ ); +} diff --git a/frontend/src/contexts/AuthContext.jsx b/frontend/src/contexts/AuthContext.jsx new file mode 100644 index 0000000..9643ed6 --- /dev/null +++ b/frontend/src/contexts/AuthContext.jsx @@ -0,0 +1,58 @@ +import { createContext, useContext, useState, useEffect, useCallback } from 'react'; +import { login as apiLogin, logout as apiLogout, getMe } from '../api/auth'; +import { updateSignatureAppearancePreference as apiUpdateSignatureAppearancePreference } from '../api/users'; + +const AuthContext = createContext(null); + +export function AuthProvider({ children }) { + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const token = localStorage.getItem('accessToken'); + if (token) { + getMe() + .then(({ data }) => setUser(data.data.user)) + .catch(() => { + localStorage.removeItem('accessToken'); + localStorage.removeItem('refreshToken'); + }) + .finally(() => setLoading(false)); + } else { + setLoading(false); + } + }, []); + + 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); + 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'); + setUser(null); + }, []); + + const updateSignatureAppearancePreference = useCallback(async (signaturePreferences) => { + const { data } = await apiUpdateSignatureAppearancePreference(signaturePreferences); + setUser((currentUser) => currentUser + ? { ...currentUser, signaturePreferences: data.data.signaturePreferences } + : currentUser); + return data.data.signaturePreferences; + }, []); + + return ( + + {children} + + ); +} + +export const useAuth = () => useContext(AuthContext); diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..dffce62 --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,1939 @@ +@import url('https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&family=Roboto+Mono:wght@400;500&display=swap'); + +/* ─── Reset & Base ───────────────────────────────────────────── */ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +:root { + --c-primary: #1565c0; + --c-primary-light: #e3f2fd; + --c-primary-dark: #0d47a1; + --c-surface: #ffffff; + --c-bg: #f0f4ff; + --c-sidebar-bg: #ffffff; + --c-text: #1a1a2e; + --c-muted: #6b7a99; + --c-border: #c5d0e8; + --c-success-bg: #e8f5e9; + --c-success: #2e7d32; + --c-warn-bg: #fff3e0; + --c-warn: #e65100; + --c-info-bg: #e3f2fd; + --c-info: #1565c0; + --c-danger-bg: #ffebee; + --c-danger: #c62828; + --primary: var(--c-primary); + --primary-dark: var(--c-primary-dark); + --primary-light: var(--c-primary-light); + --success: var(--c-success); + --success-light: var(--c-success-bg); + --danger: var(--c-danger); + --danger-light: var(--c-danger-bg); + --warning: var(--c-warn); + --warning-light: var(--c-warn-bg); + --gray-50: #f7f9ff; + --gray-100: #eef3ff; + --gray-200: #dbe5f6; + --gray-300: #c5d0e8; + --gray-400: #97a7c6; + --gray-500: #6b7a99; + --gray-600: #52627f; + --gray-700: #33415c; + --gray-800: #1f2a44; + --gray-900: #1a1a2e; + --sidebar-w: 220px; + --radius: 8px; + --radius-lg: 12px; + --shadow: 0 4px 16px rgba(21, 101, 192, 0.05); + --shadow-md: 0 10px 30px rgba(19, 45, 89, 0.08); +} + +html, body, #root { + height: 100%; + font-family: 'Roboto', system-ui, -apple-system, sans-serif; + font-size: 13px; + color: var(--c-text); + background: var(--c-bg); + line-height: 1.45; +} + +body { + overflow-x: hidden; + overflow-y: auto; +} + +a { + color: inherit; +} + +button, +input, +textarea, +select { + font: inherit; +} + +/* ─── Layout ─────────────────────────────────────────────────── */ +.app-layout { + display: flex; + min-height: 100vh; + background: var(--c-bg); + overflow: visible; +} + +.mobile-topbar, +.mobile-sidebar-backdrop, +.mobile-sidebar-close { + display: none; +} + +.sidebar { + width: var(--sidebar-w); + flex-shrink: 0; + background: var(--c-sidebar-bg); + border-right: 0.5px solid var(--c-border); + display: flex; + flex-direction: column; + position: sticky; + top: 0; + height: 100vh; + color: var(--c-text); +} + +.sidebar-brand { + padding: 18px 16px 14px; + display: flex; + align-items: center; + gap: 10px; + border-bottom: 0.5px solid var(--c-border); +} + +.sidebar-brand-mark { + 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-copy { + display: flex; + flex-direction: column; + min-width: 0; +} + +.sidebar-brand-copy span { + font-size: 14px; + font-weight: 500; + color: var(--c-text); +} + +.sidebar-brand-copy small { + font-size: 10px; + color: var(--c-muted); +} + +.sidebar-nav { + flex: 1; + padding: 12px 8px 4px; + display: flex; + flex-direction: column; + gap: 12px; +} + +.sidebar-group { + display: flex; + flex-direction: column; + gap: 2px; +} + +.nav-group-label { + font-size: 10px; + font-weight: 500; + color: var(--c-muted); + padding: 0 8px; + margin-bottom: 4px; + text-transform: uppercase; + letter-spacing: .5px; +} + +.nav-item { + display: flex; + align-items: center; + gap: 8px; + padding: 7px 8px; + border-radius: 8px; + cursor: pointer; + color: var(--c-muted); + text-decoration: none; + margin-bottom: 1px; + transition: background .15s ease, color .15s ease; +} + +.nav-item span { + font-size: 12px; +} + +.nav-item:hover:not(.active) { + background: var(--gray-50); + color: var(--c-text); +} + +.nav-item.active { + background: var(--c-info-bg); + color: var(--c-primary); +} + +.nav-item.active span { + font-weight: 500; +} + +.nav-item--static { + opacity: 0.8; +} + +.sidebar-group--disabled .nav-item { + cursor: default; +} + +.sidebar-footer { + margin-top: auto; + padding: 12px 8px; + border-top: 0.5px solid var(--c-border); + display: flex; + align-items: center; + gap: 8px; +} + +.user-info-button { + flex: 1; + min-width: 0; + border: none; + background: none; + padding: 6px 8px; + border-radius: 8px; + cursor: pointer; + display: flex; + align-items: center; + gap: 8px; + color: inherit; + text-align: left; +} + +.user-info-button:hover { + background: var(--gray-50); +} + +.user-info { + flex: 1; + min-width: 0; + display: flex; + align-items: center; + gap: 8px; +} + +.user-avatar { + width: 28px; + height: 28px; + border-radius: 50%; + background: var(--c-primary); + color: #fff; + display: flex; + align-items: center; + justify-content: center; + font-size: 11px; + font-weight: 500; + flex-shrink: 0; +} + +.user-details { + min-width: 0; +} + +.user-name { + display: block; + font-size: 12px; + font-weight: 500; + color: var(--c-text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.user-email { + display: block; + font-size: 10px; + color: var(--c-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.btn-logout { + width: 30px; + height: 30px; + border: none; + border-radius: 8px; + background: none; + color: var(--c-muted); + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; +} + +.btn-logout:hover { + background: var(--gray-50); + color: var(--c-primary); +} + +.main-content { + flex: 1; + min-width: 0; + overflow: visible; + background: var(--c-bg); +} + +.topbar { + background: var(--c-surface); + border-bottom: 0.5px solid var(--c-border); + padding: 0 24px; + height: 52px; + display: flex; + align-items: center; + gap: 12px; + position: sticky; + top: 0; + z-index: 20; +} + +.topbar-title { + flex: 1; + font-size: 15px; + font-weight: 500; + color: var(--c-text); +} + +.search-box { + width: 220px; + display: flex; + align-items: center; + gap: 6px; + background: var(--c-bg); + border: 0.5px solid var(--c-border); + border-radius: 8px; + padding: 6px 10px; + color: var(--c-muted); +} + +.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; +} + +/* ─── Page ───────────────────────────────────────────────────── */ +.page { + padding: 20px 24px; + max-width: 1380px; + min-height: calc(100vh - 52px); +} + +.page-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + margin-bottom: 24px; + gap: 16px; +} + +.page-header h2 { font-size: 20px; font-weight: 500; color: var(--c-text); } +.page-header p { font-size: 12px; color: var(--c-muted); margin-top: 2px; } + +.header-actions { display: flex; align-items: center; gap: 8px; } + +/* ─── Auth ───────────────────────────────────────────────────── */ +.auth-page { + min-height: 100vh; + display: flex; align-items: center; justify-content: center; + background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%); + padding: 20px; +} + +.auth-card { + background: #fff; + border-radius: 12px; + padding: 36px 40px; + width: 100%; + max-width: 420px; + box-shadow: var(--shadow-md); +} + +.auth-logo { text-align: center; margin-bottom: 28px; color: var(--primary); } +.auth-logo h1 { font-size: 24px; font-weight: 800; color: var(--gray-900); margin-top: 8px; } +.auth-logo p { font-size: 13px; color: var(--gray-500); margin-top: 4px; } + +.auth-form { display: flex; flex-direction: column; gap: 16px; } +.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; } + +/* ─── Form fields ────────────────────────────────────────────── */ +.field { display: flex; flex-direction: column; gap: 5px; } +.field label { font-size: 12.5px; font-weight: 600; color: var(--gray-700); display: flex; align-items: center; gap: 4px; } +.field input, .field select, .field textarea { + padding: 9px 12px; + border: 1px solid var(--gray-300); + border-radius: 6px; + font-size: 13.5px; + transition: border-color .15s, box-shadow .15s; + outline: none; + background: #fff; +} +.field input:focus, .field select:focus, .field textarea:focus { + border-color: var(--primary); + box-shadow: 0 0 0 3px rgba(79,70,229,.1); +} +.field-hint { font-size: 11.5px; color: var(--gray-400); margin-top: 2px; } +.password-rules { + display: flex; + flex-direction: column; + gap: 4px; + margin-top: 4px; +} +.password-rule { + display: flex; + align-items: center; + gap: 6px; + font-size: 11.5px; +} +.password-rule--ok { + color: var(--success); +} +.password-rule--pending { + color: var(--gray-500); +} + +/* ─── Buttons ────────────────────────────────────────────────── */ +.btn { + display: inline-flex; align-items: center; gap: 6px; + padding: 7px 14px; + border-radius: 8px; + font-size: 12px; font-weight: 500; + border: none; cursor: pointer; + transition: opacity .15s, transform .1s; + text-decoration: none; +} +.btn:active { transform: scale(.98); } +.btn:disabled { opacity: .6; cursor: not-allowed; } + +.btn-primary { background: var(--primary); color: #fff; } +.btn-primary:hover:not(:disabled) { background: var(--primary-dark); } + +.btn-secondary { background: var(--gray-50); color: var(--c-muted); border: 0.5px solid var(--c-border); } +.btn-secondary:hover:not(:disabled) { background: var(--gray-100); color: var(--c-text); } + +.btn-ghost { background: transparent; color: var(--c-muted); border: 0.5px solid var(--c-border); } +.btn-ghost:hover:not(:disabled) { background: var(--gray-50); color: var(--c-primary); } + +.btn-full { width: 100%; justify-content: center; } + +.btn-icon { + background: none; border: 0.5px solid var(--c-border); cursor: pointer; + width: 28px; height: 28px; border-radius: 8px; + color: var(--c-muted); display: inline-flex; align-items: center; justify-content: center; + transition: background .15s, color .15s; +} +.btn-icon:hover { background: var(--gray-50); color: var(--c-primary); } +.btn-icon.danger:hover { background: var(--danger-light); color: var(--danger); } + +/* ─── Dashboard stats ────────────────────────────────────────── */ +.stats-grid, +.stats-row { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 12px; + margin-bottom: 20px; +} + +.stat-card { + background: var(--c-surface); + border: 0.5px solid var(--c-border); + border-radius: 12px; + padding: 14px 16px; + cursor: pointer; + transition: box-shadow .15s ease, transform .15s ease; +} + +.stat-card:hover { + box-shadow: var(--shadow); + transform: translateY(-1px); +} + +.stat-label { + font-size: 11px; + color: var(--c-muted); + margin-bottom: 6px; + font-weight: 400; +} + +.stat-value, +.stat-val { + font-size: 22px; + font-weight: 500; + color: var(--c-text); + margin-bottom: 4px; +} + +.stat-badge { + display: inline-flex; + align-items: center; + gap: 3px; + font-size: 10px; + padding: 2px 7px; + border-radius: 20px; +} + +.badge-green { background: var(--c-success-bg); color: var(--c-success); } +.badge-orange { background: var(--c-warn-bg); color: var(--c-warn); } +.badge-blue { background: var(--c-info-bg); color: var(--c-info); } + +.quick-actions h3 { font-size: 13px; font-weight: 500; margin-bottom: 12px; color: var(--c-text); } +.actions-row { display: flex; gap: 10px; flex-wrap: wrap; } + +/* ─── Table ──────────────────────────────────────────────────── */ +.two-col { + display: grid; + grid-template-columns: 1fr 360px; + gap: 16px; +} + +.right-panel { + display: flex; + flex-direction: column; + gap: 16px; +} + +.panel, +.table-wrapper { + background: var(--c-surface); + border: 0.5px solid var(--c-border); + border-radius: 12px; + overflow: hidden; +} + +.panel-header { + padding: 12px 16px; + border-bottom: 0.5px solid var(--c-border); + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.panel-title { + font-size: 13px; + font-weight: 500; + color: var(--c-text); +} + +.panel-action { + font-size: 11px; + color: var(--c-primary); + cursor: pointer; +} + +.data-table { width: 100%; border-collapse: collapse; min-width: 720px; } +.data-table th { + background: var(--gray-50); border-bottom: 0.5px solid var(--c-border); + padding: 8px 16px; text-align: left; + font-size: 10px; font-weight: 500; text-transform: uppercase; + letter-spacing: .04em; color: var(--c-muted); +} +.data-table td { padding: 10px 16px; border-bottom: 0.5px solid var(--c-border); font-size: 11px; } +.data-table tr:last-child td { border-bottom: none; } +.data-table tr:hover td { background: var(--gray-50); } + +.table-header { + display: grid; + grid-template-columns: 1fr 90px 80px 80px 44px; + gap: 8px; + padding: 8px 16px; + background: var(--gray-50); + border-bottom: 0.5px solid var(--c-border); +} + +.th { + font-size: 10px; + font-weight: 500; + color: var(--c-muted); + text-transform: uppercase; + letter-spacing: .4px; +} + +.table-row { + display: grid; + grid-template-columns: 1fr 90px 80px 80px 44px; + gap: 8px; + padding: 10px 16px; + border-bottom: 0.5px solid var(--c-border); + align-items: center; +} + +.table-row:last-child { + border-bottom: none; +} + +.table-row:hover { + background: var(--gray-50); +} + +.doc-name, +.doc-cell { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +.doc-thumb { + width: 28px; + height: 34px; + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.doc-n { + font-size: 12px; + font-weight: 500; + color: var(--c-text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.doc-m, +.td { + font-size: 10px; + color: var(--c-muted); +} + +.actions-cell { display: flex; align-items: center; gap: 4px; } + +.action-ico { + width: 28px; + height: 28px; + border-radius: 8px; + border: 0.5px solid var(--c-border); + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + color: var(--c-muted); + background: transparent; +} + +.action-ico:hover { + background: var(--gray-50); + color: var(--c-primary); +} + +/* ─── Badges ─────────────────────────────────────────────────── */ +.badge { + display: inline-block; padding: 2px 8px; + border-radius: 12px; font-size: 11px; font-weight: 600; +} +.badge-pending { background: var(--warning-light); color: var(--warning); } +.badge-signed { background: var(--success-light); color: var(--success); } +.badge-partial { background: var(--primary-light); color: var(--primary); } +.badge-rejected { background: var(--danger-light); color: var(--danger); } + +.status-pill { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 3px 8px; + border-radius: 20px; + font-size: 10px; + font-weight: 500; + white-space: nowrap; +} + +.pill-pending { background: var(--c-warn-bg); color: var(--c-warn); } +.pill-signed { background: var(--c-success-bg); color: var(--c-success); } +.pill-review { background: var(--c-info-bg); color: var(--c-info); } + +.dot { + width: 5px; + height: 5px; + border-radius: 50%; + flex-shrink: 0; + background: currentColor; +} + +/* ─── Certificates ───────────────────────────────────────────── */ +.certs-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 14px; +} + +.cert-card { + background: #fff; + border: 1px solid var(--gray-200); + border-radius: 10px; + padding: 16px; +} +.cert-card.inactive { opacity: .6; } + +.cert-header { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; color: var(--primary); font-weight: 600; } +.cert-alias { flex: 1; font-size: 14px; } +.cert-details { font-size: 12.5px; color: var(--gray-600); display: flex; flex-direction: column; gap: 3px; } +.cert-actions { margin-top: 10px; display: flex; justify-content: flex-end; } +.cert-warning { + display: flex; align-items: center; gap: 5px; + font-size: 11.5px; color: var(--danger); + background: var(--danger-light); border-radius: 5px; + padding: 5px 8px; margin-top: 8px; +} +.cert-footer { + display: flex; align-items: center; justify-content: space-between; + margin-top: 10px; padding-top: 8px; + border-top: 1px solid var(--gray-100); +} +.cert-password-note { + display: flex; align-items: center; gap: 4px; + font-size: 11px; color: var(--gray-400); +} + +.form-card { + background: #fff; + border: 0.5px solid var(--c-border); + border-radius: 12px; + padding: 20px 24px; + margin-bottom: 20px; +} +.form-card h3 { font-size: 14px; font-weight: 500; margin-bottom: 16px; color: var(--c-text); } +.form-card form { display: flex; flex-direction: column; gap: 14px; } + +.form-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 8px; } + +/* ─── Sign page ──────────────────────────────────────────────── */ +.sign-layout { + display: grid; + grid-template-columns: 1fr 340px; + gap: 16px; + align-items: start; +} + +.sign-form-card { + background: #fff; + border: 0.5px solid var(--c-border); + border-radius: 12px; + padding: 24px; +} + +.sign-form-card form { display: flex; flex-direction: column; gap: 14px; } + +.sign-info-panel { display: flex; flex-direction: column; gap: 14px; } + +.result-card { + background: var(--success-light); + border: 0.5px solid #86efac; + border-radius: 12px; + padding: 20px; + text-align: center; + color: var(--success); +} +.result-card h3 { margin: 8px 0 4px; font-size: 16px; } +.result-card code { font-size: 11px; word-break: break-all; display: block; margin-top: 8px; } + +.sigs-list { background: #fff; border: 0.5px solid var(--c-border); border-radius: 12px; padding: 16px; } +.sigs-list h3 { font-size: 13px; font-weight: 500; margin-bottom: 10px; color: var(--c-text); } +.sig-item { display: flex; align-items: flex-start; gap: 10px; padding: 6px 0; } +.sig-icon { color: var(--success); margin-top: 2px; flex-shrink: 0; } +.sig-item strong { font-size: 13px; } +.sig-item p { font-size: 12px; color: var(--gray-500); } +.sig-item small { font-size: 11px; color: var(--gray-400); } + +.info-box { + background: var(--primary-light); + border: 0.5px solid var(--c-border); + border-radius: 12px; + padding: 16px; + color: var(--primary-dark); +} +.info-box h4 { font-size: 13px; font-weight: 500; margin-bottom: 8px; } +.info-box ol { padding-left: 16px; font-size: 12.5px; display: flex; flex-direction: column; gap: 4px; } + +/* ─── States ─────────────────────────────────────────────────── */ +.empty-state { + display: flex; flex-direction: column; align-items: center; gap: 12px; + padding: 60px 20px; color: var(--gray-400); text-align: center; +} + +.loading-area { display: flex; justify-content: center; padding: 60px; } + +.loading-screen { + display: flex; align-items: center; justify-content: center; + height: 100vh; background: var(--gray-50); +} + +.spinner { + width: 32px; height: 32px; + border: 3px solid var(--gray-200); + border-top-color: var(--primary); + border-radius: 50%; + animation: spin .7s linear infinite; +} + +.spin-soft { + animation: spin .9s linear infinite; +} + +@keyframes spin { to { transform: rotate(360deg); } } + +/* ─── Steps ──────────────────────────────────────────────────── */ +.steps-bar, +.step-bar { + display: flex; + align-items: center; + margin-bottom: 20px; + flex-wrap: wrap; + gap: 4px; +} + +.step { + display: flex; + align-items: center; + gap: 6px; + flex: initial; +} + +.step::after { + display: none; +} + +.step-num, +.step-dot { + width: 22px; + height: 22px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 10px; + font-weight: 500; + flex-shrink: 0; +} + +.step-num.done, +.step-dot.done { + background: var(--c-success-bg); + color: var(--c-success); +} + +.step-num.active, +.step-dot.active { + background: var(--c-primary); + color: #fff; +} + +.step-num.todo, +.step-dot { + background: var(--gray-50); + color: var(--c-muted); + border: 0.5px solid var(--c-border); +} + +.step-label { + font-size: 11px; +} + +.step-label.done-label { + color: var(--c-success); +} + +.step-label.active-label { + color: var(--c-text); + font-weight: 500; +} + +.step-label.todo-label { + color: var(--c-muted); +} + +.step-line { + flex: 1; + min-width: 20px; + height: 0.5px; + background: var(--c-border); + margin: 0 8px; +} + +.step-line.done-line { + background: var(--c-success); +} + +.step-nav { + display: flex; align-items: center; gap: 16px; + margin-bottom: 16px; +} +.step-desc { font-size: 13px; color: var(--gray-600); } + +.verify-page { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + background: linear-gradient(180deg, #f4f7fb 0%, #eef3ff 100%); +} + +.verify-card { + width: min(760px, 100%); + background: #fff; + border: 1px solid var(--gray-200); + border-radius: 18px; + padding: 28px; + box-shadow: 0 20px 60px rgba(15, 23, 42, 0.08); + display: flex; + flex-direction: column; + gap: 20px; +} + +.verify-status { + display: flex; + align-items: flex-start; + gap: 14px; + border-radius: 14px; + padding: 18px; +} + +.verify-status h1 { + margin: 0 0 6px; + font-size: 24px; +} + +.verify-status p { + margin: 0; + color: var(--gray-600); +} + +.verify-status--valid { + background: #ecfdf3; + color: #166534; +} + +.verify-status--invalid { + background: #fef2f2; + color: #b91c1c; +} + +.verify-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.verify-item { + display: flex; + gap: 10px; + align-items: flex-start; + background: var(--gray-50); + border-radius: 12px; + padding: 14px; +} + +.verify-item strong, +.verify-details strong { + color: var(--gray-900); +} + +.verify-item span, +.verify-details p { + color: var(--gray-600); +} + +.verify-item div { + display: flex; + flex-direction: column; + gap: 4px; +} + +.verify-details { + border-top: 1px solid var(--gray-200); + padding-top: 16px; + display: flex; + flex-direction: column; + gap: 8px; +} + +.documents-sections { + display: flex; + flex-direction: column; + gap: 24px; +} + +.documents-section { + display: flex; + flex-direction: column; + gap: 12px; +} + +.section-header { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; +} + +.section-header h3 { + margin: 0; + font-size: 18px; + color: var(--gray-900); +} + +.section-header p { + margin: 0; + font-size: 13px; + color: var(--gray-500); +} + +.empty-state.compact { + min-height: 180px; +} + +.sig-tabs { + display: flex; + gap: 6px; +} + +.sig-tab { + font-size: 10px; + padding: 3px 8px; + border-radius: 20px; + cursor: default; +} + +.sig-tab.active { + background: var(--c-info-bg); + color: var(--c-primary); + font-weight: 500; +} + +.sig-tab:not(.active) { + background: var(--gray-50); + color: var(--c-muted); +} + +.toolbar { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 16px; + border-bottom: 0.5px solid var(--c-border); + flex-wrap: wrap; +} + +.tool-btn { + width: 28px; + height: 28px; + border-radius: 8px; + border: 0.5px solid var(--c-border); + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + background: var(--c-surface); + color: var(--c-muted); +} + +.tool-btn.active-tool { + border-color: var(--c-primary); + background: var(--c-info-bg); + color: var(--c-primary); +} + +.tool-sep { + width: 0.5px; + height: 18px; + background: var(--c-border); + margin: 0 2px; +} + +.color-swatch { + width: 14px; + height: 14px; + border-radius: 50%; + cursor: pointer; + border: 1.5px solid transparent; +} + +.color-swatch.selected { + border-color: var(--c-primary); +} + +.tool-size-group { + display: flex; + align-items: center; + gap: 4px; +} + +.tool-size { + width: 22px; + border-radius: 999px; + background: var(--c-border); +} + +.tool-size--sm { height: 2px; } +.tool-size--md { height: 4px; background: var(--c-primary-dark); } +.tool-size--lg { height: 6px; } + +.panel-body { + padding: 12px 16px; +} + +.panel-empty { + padding: 20px 16px; + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + color: var(--c-muted); +} + +.cert-v--success { + color: var(--c-success); +} + +/* ─── PDF Placer ─────────────────────────────────────────────── */ +.pdf-placer { display: flex; flex-direction: column; gap: 12px; } + +.pdf-placer-toolbar { + display: flex; align-items: center; justify-content: space-between; + background: var(--gray-800); color: #fff; + padding: 8px 14px; border-radius: 8px 8px 0 0; + font-size: 13px; +} +.pdf-placer-hint { color: var(--gray-300); } +.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-canvas { + border: 2px dashed var(--primary); + border-radius: 0 0 8px 8px; + overflow: hidden; + user-select: none; +} +.pdf-placer-canvas .react-pdf__Page { display: block; } + +.pdf-placer-actions { + display: flex; justify-content: flex-end; gap: 8px; +} + +/* Signature preview box overlaid on PDF */ +.sig-preview-box { + position: absolute; + background: rgba(235, 237, 255, 0.92); + border: 1.5px solid var(--primary); + border-radius: 4px; + overflow: hidden; + pointer-events: none; + box-shadow: 0 2px 8px rgba(79,70,229,.25); +} +.sig-preview-box--professional { + background: rgba(247, 248, 252, 0.96); + border-color: #1f2937; +} +.sig-preview-box--qr { + background: transparent; + border-color: transparent; + box-shadow: none; +} +.sig-preview-box--qr[data-background='soft'] { + background: rgba(242, 244, 255, 0.96); +} +.sig-preview-box--qr[data-border='line'] { + border-color: var(--primary); + box-shadow: 0 2px 8px rgba(79,70,229,.18); +} +.sig-preview-box--image { + background: rgba(255, 255, 255, 0.96); + border-color: #23915a; +} +.sig-preview-bar { + position: absolute; left: 0; top: 0; bottom: 0; + width: 4px; background: var(--primary); +} +.sig-preview-body { + display: flex; align-items: center; gap: 6px; + padding: 6px 8px 6px 12px; height: 100%; +} +.sig-preview-body--qr { + padding: 6px 6px; +} +.sig-preview-body--professional { + display: flex; + flex-direction: column; + align-items: stretch; + gap: 0; + padding: 0; +} +.sig-preview-body--image { + padding: 6px 8px; +} +.sig-preview-badge { + font-size: 9px; font-weight: 800; color: var(--primary); + background: var(--primary-light); border-radius: 3px; + padding: 2px 4px; flex-shrink: 0; +} +.sig-preview-text { display: flex; flex-direction: column; overflow: hidden; } +.sig-preview-text strong { font-size: 10px; color: var(--gray-800); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.sig-preview-text small { font-size: 8px; color: var(--gray-500); } +.sig-preview-body--qr .sig-preview-text strong { + font-weight: 500; +} +.sig-preview-qr-box { + width: 44px; + height: 44px; + border: 1px solid transparent; + background: transparent; + border-radius: 4px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + flex-shrink: 0; +} +.sig-preview-qr-box[data-background='soft'] { + background: #fff; +} +.sig-preview-qr-box[data-border='line'] { + border-color: var(--primary); +} +.sig-preview-qr-pattern { + width: 26px; + height: 26px; + background: + linear-gradient(90deg, #1f1b6e 12%, transparent 12%, transparent 24%, #1f1b6e 24%, #1f1b6e 36%, transparent 36%, transparent 48%, #1f1b6e 48%, #1f1b6e 60%, transparent 60%, transparent 72%, #1f1b6e 72%, #1f1b6e 84%, transparent 84%), + linear-gradient(#1f1b6e 12%, transparent 12%, transparent 24%, #1f1b6e 24%, #1f1b6e 36%, transparent 36%, transparent 48%, #1f1b6e 48%, #1f1b6e 60%, transparent 60%, transparent 72%, #1f1b6e 72%, #1f1b6e 84%, transparent 84%); +} +.sig-preview-qr-box span { + font-size: 7px; + color: var(--gray-500); + margin-top: 2px; +} +.sig-preview-pro-header { + background: #1f2937; + color: #fff; + font-size: 8px; + font-weight: 700; + letter-spacing: .04em; + padding: 5px 8px; +} +.sig-preview-pro-content { + display: flex; + flex-direction: column; + gap: 4px; + padding: 6px 8px; +} +.sig-preview-pro-content strong { + font-size: 10px; + color: var(--gray-800); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.sig-preview-pro-content small { + font-size: 8px; + color: var(--gray-500); +} +.sig-preview-image-area { + width: 78px; + height: 100%; + min-height: 44px; + border-right: 1px solid rgba(35, 145, 90, 0.2); + display: flex; + align-items: center; + justify-content: center; + padding-right: 8px; + flex-shrink: 0; +} +.sig-preview-image { + max-width: 100%; + max-height: 100%; + object-fit: contain; +} +.sig-preview-image-placeholder { + font-size: 9px; + color: var(--gray-400); +} + +/* Signature style picker */ +.field-label-standalone { + display: block; + margin-bottom: 10px; + font-size: 13px; + font-weight: 600; + color: var(--gray-700); +} +.style-picker { + display: flex; + flex-direction: column; + gap: 12px; +} +.style-card-inline-tools { + display: flex; + justify-content: flex-end; + flex-wrap: wrap; + gap: 8px; +} +.style-inline-group { + display: flex; + gap: 8px; +} +.style-icon-toggle { + width: 34px; + height: 34px; + border-radius: 10px; + border: 1px solid var(--gray-200); + background: #fff; + color: var(--gray-600); + display: grid; + place-items: center; +} +.style-icon-toggle--active { + border-color: var(--primary); + color: var(--primary); + background: rgba(79, 70, 229, 0.08); + box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.08); +} +.style-picker-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} +.style-card { + background: #fff; + border: 1px solid var(--gray-200); + border-radius: 12px; + padding: 12px; + text-align: left; + display: flex; + flex-direction: column; + gap: 10px; + transition: border-color .2s ease, box-shadow .2s ease, transform .2s ease; +} +.style-card:hover { + border-color: rgba(79, 70, 229, 0.35); + box-shadow: 0 10px 24px rgba(15, 23, 42, 0.08); + transform: translateY(-1px); +} +.style-card--active { + border-color: var(--primary); + box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.12); +} +.style-card-header { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + font-weight: 700; + color: var(--gray-800); +} +.style-card-check { + margin-left: auto; + width: 20px; + height: 20px; + border-radius: 999px; + background: var(--primary); + color: #fff; + display: grid; + place-items: center; + font-size: 12px; +} +.style-card-preview { + border: 1px solid var(--gray-200); + border-radius: 10px; + background: linear-gradient(180deg, #f8faff 0%, #f2f4fb 100%); + padding: 10px; +} +.style-card-desc { + font-size: 12px; + color: var(--gray-500); + margin: 0; +} +.sig-preview { + width: 100%; + min-height: 86px; + border-radius: 8px; + overflow: hidden; + position: relative; +} +.sig-preview .sp-info { + display: flex; + flex-direction: column; + overflow: hidden; + flex: 1; + min-width: 0; +} +.sig-preview .sp-info strong, +.sig-preview .sp-pro-name { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.sig-preview .sp-info--qr strong { + white-space: normal; + line-height: 1.1; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +} +.sig-preview .sp-info span, +.sig-preview .sp-pro-fields span, +.sig-preview .sp-pro-fields b, +.sig-preview .sp-qr-hint, +.sig-preview .sp-img-placeholder { + font-size: 10px; +} +.sig-preview--classic { + background: rgba(235, 237, 255, 0.92); + border: 1px solid rgba(79, 70, 229, 0.25); +} +.sig-preview--classic .sp-bar { + position: absolute; + inset: 0 auto 0 0; + width: 5px; + background: var(--primary); +} +.sig-preview--classic .sp-body { + height: 100%; + display: flex; + align-items: center; + gap: 8px; + padding: 10px 10px 10px 16px; +} +.sig-preview--classic .sp-badge { + font-size: 10px; + font-weight: 800; + color: var(--primary); + background: rgba(99, 102, 241, 0.16); + border-radius: 4px; + padding: 4px 6px; + flex-shrink: 0; +} +.sig-preview--classic .sp-watermark { + position: absolute; + right: 8px; + bottom: 6px; + font-size: 9px; + color: var(--gray-400); +} +.sig-preview--qr { + display: flex; + align-items: stretch; + gap: 10px; + background: transparent; + border: 1px solid transparent; + padding: 10px; +} +.sig-preview--qr[data-background='soft'] { + background: #eef2ff; +} +.sig-preview--qr[data-border='line'] { + border-color: rgba(79, 70, 229, 0.25); +} +.sp-qr-box { + width: 56px; + min-width: 56px; + border-radius: 8px; + background: transparent; + border: 1px solid transparent; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 4px; +} +.sp-qr-box[data-background='soft'] { + background: #fff; +} +.sp-qr-box[data-border='line'] { + border-color: rgba(31, 27, 110, 0.2); +} +.sp-qr-svg { + width: 36px; + height: 36px; +} +.sp-qr-hint { + color: var(--gray-500); +} +.sp-divider-v { + width: 1px; + background: rgba(79, 70, 229, 0.18); +} +.sig-preview--professional { + background: #f8fafc; + border: 1px solid rgba(31, 41, 55, 0.15); +} +.sp-pro-header { + background: #1f2937; + color: #fff; + font-size: 10px; + font-weight: 700; + display: flex; + justify-content: space-between; + gap: 8px; + padding: 8px 10px; +} +.sp-pro-header span { + color: rgba(255,255,255,0.7); + font-size: 9px; + font-weight: 500; +} +.sp-pro-name { + padding: 10px 10px 6px; + font-size: 13px; + font-weight: 700; + color: var(--gray-800); +} +.sp-pro-line { + height: 1px; + background: rgba(148, 163, 184, 0.4); + margin: 0 10px; +} +.sp-pro-fields { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; + padding: 8px 10px 10px; +} +.sp-pro-fields div { + display: flex; + flex-direction: column; + gap: 2px; +} +.sig-preview--image { + display: flex; + align-items: stretch; + gap: 10px; + background: #fff; + border: 1px solid rgba(35, 145, 90, 0.2); + padding: 10px; +} +.sp-img-bar { + width: 5px; + border-radius: 999px; + background: #23915a; +} +.sp-img-area { + flex: 0 0 88px; + min-height: 58px; + display: flex; + align-items: center; + justify-content: center; +} +.sp-sig-img { + max-width: 100%; + max-height: 52px; + object-fit: contain; +} +.sp-img-placeholder { + color: var(--gray-400); +} +.image-upload-area { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; +} + +/* ─── Sign confirm ───────────────────────────────────────────── */ +.sign-confirm-card { + background: #fff; + border: 0.5px solid var(--c-border); + border-radius: 12px; + padding: 28px; + max-width: 520px; + display: flex; flex-direction: column; gap: 20px; +} +.sign-confirm-header { + display: flex; align-items: center; gap: 10px; + font-size: 15px; font-weight: 700; color: var(--gray-900); +} +.sign-confirm-details { + display: flex; flex-direction: column; gap: 6px; + font-size: 13px; color: var(--gray-600); + background: var(--gray-50); border-radius: 8px; padding: 14px; +} +.sign-confirm-details strong { color: var(--gray-800); } +.sign-confirm-actions { display: flex; gap: 10px; justify-content: flex-end; } + +/* ─── Modal ──────────────────────────────────────────────────── */ +.modal-overlay { + position: fixed; inset: 0; + background: rgba(0,0,0,.45); + display: flex; align-items: center; justify-content: center; + z-index: 1000; + padding: 20px; +} + +.modal-card { + background: #fff; + border-radius: 12px; + padding: 24px; + width: 100%; max-width: 400px; + box-shadow: 0 20px 60px rgba(0,0,0,.2); + display: flex; flex-direction: column; gap: 14px; +} + +.modal-header { + display: flex; align-items: center; justify-content: space-between; +} + +.modal-title { + display: flex; align-items: center; gap: 8px; + font-size: 15px; font-weight: 700; color: var(--gray-900); +} + +.modal-desc { + font-size: 13px; color: var(--gray-500); line-height: 1.5; +} + +.modal-actions { + display: flex; justify-content: flex-end; gap: 8px; margin-top: 4px; +} + +/* ─── PDF Viewer Modal ───────────────────────────────────────── */ +.pdf-viewer-modal { + background: #fff; + border-radius: 12px; + width: min(900px, 95vw); + max-height: 92vh; + display: flex; flex-direction: column; + box-shadow: 0 24px 80px rgba(0,0,0,.35); + overflow: hidden; +} + +.pdf-viewer-header { + display: flex; align-items: center; justify-content: space-between; + padding: 12px 16px; + border-bottom: 1px solid var(--gray-200); + background: var(--gray-50); + flex-shrink: 0; +} + +.pdf-viewer-title { + display: flex; align-items: center; gap: 8px; + font-size: 14px; font-weight: 600; color: var(--gray-800); + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} + +.pdf-viewer-controls { + display: flex; align-items: center; gap: 8px; + flex-shrink: 0; + font-size: 13px; color: var(--gray-600); +} + +.pdf-viewer-body { + flex: 1; + overflow-y: auto; + display: flex; justify-content: center; + padding: 16px; + background: var(--gray-100); +} + +.pdf-viewer-body .react-pdf__Page { + box-shadow: 0 4px 20px rgba(0,0,0,.15); +} + +/* ─── Responsive ─────────────────────────────────────────────── */ +@media (max-width: 768px) { + .app-layout { + display: block; + height: auto; + min-height: 100vh; + overflow: visible; + } + + .mobile-topbar { + position: sticky; + top: 0; + z-index: 70; + display: flex; + align-items: center; + gap: 12px; + height: 60px; + padding: 0 16px; + background: var(--c-surface); + color: var(--c-text); + border-bottom: 0.5px solid var(--c-border); + } + + .mobile-topbar-brand { + display: flex; + align-items: center; + gap: 8px; + font-size: 16px; + font-weight: 700; + } + + .mobile-menu-toggle, + .mobile-sidebar-close { + width: 36px; + height: 36px; + border: none; + border-radius: 10px; + display: inline-flex; + align-items: center; + justify-content: center; + background: var(--gray-50); + color: var(--c-text); + cursor: pointer; + } + + .mobile-sidebar-backdrop { + position: fixed; + inset: 0; + z-index: 79; + display: block; + border: none; + background: rgba(15, 23, 42, 0.55); + } + + .sidebar { + position: fixed; + top: 0; + left: 0; + bottom: 0; + z-index: 80; + width: min(84vw, 320px); + transform: translateX(-100%); + transition: transform .2s ease; + box-shadow: 0 18px 40px rgba(0,0,0,.18); + } + + .sidebar--mobile-open { + transform: translateX(0); + } + + .sidebar-brand { + justify-content: space-between; + } + + .mobile-sidebar-close { + display: inline-flex; + margin-left: auto; + flex-shrink: 0; + } + + .sidebar-footer { + align-items: flex-start; + } + + .main-content { + min-height: calc(100vh - 60px); + } + + .topbar { + padding: 12px 16px; + height: auto; + flex-wrap: wrap; + } + + .topbar-title { + flex-basis: 100%; + } + + .search-box, + .topbar-cta { + width: 100%; + } + + .page { + padding: 16px; + max-width: 100%; + } + + .page-header, + .section-header, + .step-nav, + .pdf-viewer-header, + .modal-actions, + .sign-confirm-actions, + .form-actions { + flex-direction: column; + align-items: stretch; + } + + .page-header h2 { + font-size: 20px; + } + + .header-actions { + width: 100%; + flex-wrap: wrap; + } + + .header-actions .btn, + .header-actions .btn-icon, + .form-actions .btn, + .sign-confirm-actions .btn { + width: 100%; + justify-content: center; + } + + .auth-card, + .form-card, + .sign-form-card, + .sign-confirm-card, + .verify-card, + .modal-card { + width: 100%; + max-width: 100%; + padding: 18px; + } + + .stats-grid, + .stats-row, + .certs-grid, + .style-picker-grid, + .verify-grid, + .sign-layout, + .two-col { + grid-template-columns: 1fr; + } + + .steps-bar { + flex-direction: column; + align-items: stretch; + gap: 10px; + } + + .step { + flex: none; + } + + .step::after { + display: none; + } + + .documents-section, + .documents-sections, + .sign-info-panel { + gap: 16px; + } + + .table-wrapper { + overflow-x: auto; + -webkit-overflow-scrolling: touch; + } + + .data-table td, + .data-table th { + white-space: nowrap; + } + + .actions-cell { + flex-wrap: nowrap; + } + + .pdf-placer-toolbar { + flex-direction: column; + align-items: stretch; + gap: 10px; + } + + .pdf-placer-pages { + justify-content: space-between; + } + + .pdf-placer-canvas { + overflow: auto; + } + + .pdf-placer-canvas .react-pdf__Page, + .pdf-viewer-body .react-pdf__Page { + max-width: 100%; + } + + .pdf-placer-canvas .react-pdf__Page__canvas, + .pdf-viewer-body .react-pdf__Page__canvas { + width: 100% !important; + height: auto !important; + } + + .pdf-viewer-modal { + width: 100%; + max-height: 94vh; + border-radius: 14px; + } + + .pdf-viewer-body { + padding: 10px; + } + + .pdf-viewer-controls { + width: 100%; + justify-content: space-between; + flex-wrap: wrap; + } + + .style-card-inline-tools, + .image-upload-area { + justify-content: flex-start; + } +} + +@media (max-width: 480px) { + .page { + padding: 12px; + } + + .auth-page { + padding: 12px; + } + + .auth-card { + padding: 20px 16px; + } + + .btn, + .btn-icon, + .btn-logout, + .mobile-menu-toggle, + .mobile-sidebar-close { + min-height: 40px; + } + + .page-header { + margin-bottom: 18px; + } + + .section-header h3 { + font-size: 16px; + } + + .doc-name { + min-width: 180px; + } +} diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx new file mode 100644 index 0000000..b9a1a6d --- /dev/null +++ b/frontend/src/main.jsx @@ -0,0 +1,10 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import './index.css' +import App from './App.jsx' + +createRoot(document.getElementById('root')).render( + + + , +) diff --git a/frontend/src/pages/auth/ConfirmPasswordChangePage.jsx b/frontend/src/pages/auth/ConfirmPasswordChangePage.jsx new file mode 100644 index 0000000..bc9ce9c --- /dev/null +++ b/frontend/src/pages/auth/ConfirmPasswordChangePage.jsx @@ -0,0 +1,57 @@ +import { useEffect, useState } from 'react'; +import { Link, useParams } from 'react-router-dom'; +import { CheckCircle2, MailWarning, LoaderCircle } from 'lucide-react'; +import { confirmPasswordChange } from '../../api/auth'; + +export default function ConfirmPasswordChangePage() { + const { token } = useParams(); + const [status, setStatus] = useState('loading'); + const [message, setMessage] = useState('Confirmando cambio de contraseña...'); + + useEffect(() => { + let active = true; + + confirmPasswordChange(token) + .then(({ data }) => { + if (!active) return; + setStatus('success'); + setMessage(data.data.message || 'Contraseña cambiada correctamente.'); + }) + .catch((error) => { + if (!active) return; + setStatus('error'); + setMessage(error.response?.data?.error || 'El enlace no es válido o ya expiró.'); + }); + + return () => { + active = false; + }; + }, [token]); + + return ( +
+
+
+ {status === 'loading' && } + {status === 'success' && } + {status === 'error' && } +

Cambio de contraseña

+

{message}

+
+ +
+ {status === 'success' && ( + + Ir a iniciar sesión + + )} + {status === 'error' && ( + + Volver + + )} +
+
+
+ ); +} diff --git a/frontend/src/pages/auth/LoginPage.jsx b/frontend/src/pages/auth/LoginPage.jsx new file mode 100644 index 0000000..b11a9de --- /dev/null +++ b/frontend/src/pages/auth/LoginPage.jsx @@ -0,0 +1,64 @@ +import { useState } from 'react'; +import { useNavigate, Link } from 'react-router-dom'; +import { useAuth } from '../../contexts/AuthContext'; +import { PenTool } from 'lucide-react'; +import toast from 'react-hot-toast'; + +export default function LoginPage() { + const { login } = useAuth(); + const navigate = useNavigate(); + const [form, setForm] = useState({ email: '', password: '' }); + const [loading, setLoading] = useState(false); + + const handleChange = (e) => setForm((f) => ({ ...f, [e.target.name]: e.target.value })); + + const handleSubmit = async (e) => { + e.preventDefault(); + setLoading(true); + try { + await login(form.email, form.password); + navigate('/dashboard'); + } catch (err) { + const code = err.response?.data?.code; + const message = err.response?.data?.error || '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 === 'MAIL_SEND_FAILED') { + toast.error(message); + } else { + toast.error(message); + } + } finally { + setLoading(false); + } + }; + + return ( +
+
+
+ +

LinkedSign

+

Firma digital segura

+
+
+
+ + +
+
+ + +
+ +
+

+ ¿No tienes cuenta? Registrarse +

+
+
+ ); +} diff --git a/frontend/src/pages/auth/RegisterPage.jsx b/frontend/src/pages/auth/RegisterPage.jsx new file mode 100644 index 0000000..6450fbf --- /dev/null +++ b/frontend/src/pages/auth/RegisterPage.jsx @@ -0,0 +1,91 @@ +import { useState } from 'react'; +import { useNavigate, Link } from 'react-router-dom'; +import { register } from '../../api/auth'; +import { PenTool } from 'lucide-react'; +import toast from 'react-hot-toast'; + +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 RegisterPage() { + const navigate = useNavigate(); + const [form, setForm] = useState({ email: '', password: '', confirmPassword: '' }); + const [loading, setLoading] = useState(false); + const passwordChecks = getPasswordChecks(form.password); + const isPasswordStrong = passwordChecks.every((check) => check.valid); + + const handleChange = (e) => setForm((f) => ({ ...f, [e.target.name]: e.target.value })); + + const handleSubmit = async (e) => { + e.preventDefault(); + if (!isPasswordStrong) { + toast.error('La contraseña no cumple con los requisitos de seguridad'); + return; + } + if (form.password !== form.confirmPassword) { + toast.error('Las contraseñas no coinciden'); + return; + } + setLoading(true); + try { + await register({ + email: form.email, + password: form.password, + }); + toast.success('Cuenta creada. Revisa tu correo para confirmar tu usuario.'); + navigate('/login'); + } catch (err) { + toast.error(err.response?.data?.error || 'Error al registrarse'); + } finally { + setLoading(false); + } + }; + + return ( +
+
+
+ +

LinkedSign

+

Crear nueva cuenta

+
+
+
+ + +
+
+ + +
+ {passwordChecks.map((check) => ( +
+ {check.valid ? '✓' : '•'} + {check.label} +
+ ))} +
+
+
+ + +
+ +
+

+ ¿Ya tienes cuenta? Iniciar sesión +

+
+
+ ); +} diff --git a/frontend/src/pages/auth/VerifyEmailPage.jsx b/frontend/src/pages/auth/VerifyEmailPage.jsx new file mode 100644 index 0000000..2e96428 --- /dev/null +++ b/frontend/src/pages/auth/VerifyEmailPage.jsx @@ -0,0 +1,57 @@ +import { useEffect, useState } from 'react'; +import { Link, useParams } from 'react-router-dom'; +import { CheckCircle2, MailWarning, LoaderCircle } from 'lucide-react'; +import { verifyEmail } from '../../api/auth'; + +export default function VerifyEmailPage() { + const { token } = useParams(); + const [status, setStatus] = useState('loading'); + const [message, setMessage] = useState('Validando enlace de confirmación...'); + + useEffect(() => { + let active = true; + + verifyEmail(token) + .then(({ data }) => { + if (!active) return; + setStatus('success'); + setMessage(data.data.message || 'Correo confirmado correctamente.'); + }) + .catch((error) => { + if (!active) return; + setStatus('error'); + setMessage(error.response?.data?.error || 'El enlace no es válido o ya expiró.'); + }); + + return () => { + active = false; + }; + }, [token]); + + return ( +
+
+
+ {status === 'loading' && } + {status === 'success' && } + {status === 'error' && } +

Confirmación de cuenta

+

{message}

+
+ +
+ {status === 'success' && ( + + Ir a iniciar sesión + + )} + {status === 'error' && ( + + Volver a registro + + )} +
+
+
+ ); +} diff --git a/frontend/src/pages/certificates/CertificatesPage.jsx b/frontend/src/pages/certificates/CertificatesPage.jsx new file mode 100644 index 0000000..cb8d2ca --- /dev/null +++ b/frontend/src/pages/certificates/CertificatesPage.jsx @@ -0,0 +1,210 @@ +import { useEffect, useState, useRef } from 'react'; +import { listCertificates, uploadCertificate, deleteCertificate } from '../../api/certificates'; +import { Shield, Trash2, Upload, RefreshCw, Plus, AlertTriangle, KeyRound } from 'lucide-react'; +import toast from 'react-hot-toast'; +import PasswordModal from '../../components/ui/PasswordModal'; + +const formatDate = (d) => d ? new Date(d).toLocaleDateString('es-EC') : '—'; + +const certStatus = (validTo) => { + if (!validTo) return null; + const now = new Date(); + const exp = new Date(validTo); + const daysLeft = Math.floor((exp - now) / (1000 * 60 * 60 * 24)); + if (daysLeft < 0) return { label: 'Vencido', cls: 'badge-rejected' }; + if (daysLeft <= 30) return { label: `Vence en ${daysLeft}d`, cls: 'badge-pending' }; + return { label: 'Vigente', cls: 'badge-signed' }; +}; + +const chainStatusMeta = (chainStatus, certificateCount) => { + if (chainStatus === 'complete') { + return { + label: `Cadena incluida (${certificateCount || 0})`, + text: 'El archivo P12 incluye más de un certificado y aparenta traer cadena embebida.', + }; + } + + if (chainStatus === 'leaf_only') { + return { + label: 'Sin cadena completa', + text: 'El archivo P12 parece contener solo el certificado del firmante.', + }; + } + + return null; +}; + +export default function CertificatesPage() { + const [certs, setCerts] = useState([]); + const [loading, setLoading] = useState(true); + const [showForm, setShowForm] = useState(false); + const [form, setForm] = useState({ alias: '', password: '' }); + const [file, setFile] = useState(null); + const [uploading, setUploading] = useState(false); + const [pendingUpload, setPendingUpload] = useState(null); // { file, alias } — waiting for password + const fileInputRef = useRef(); + + const load = () => { + setLoading(true); + listCertificates() + .then(({ data }) => setCerts(data.data)) + .catch(() => toast.error('Error al cargar certificados')) + .finally(() => setLoading(false)); + }; + + useEffect(() => { load(); }, []); + + const doUpload = async (uploadFile, password, alias, fromModal = false) => { + setUploading(true); + try { + await uploadCertificate(uploadFile, password, alias || uploadFile.name); + toast.success('Certificado registrado correctamente'); + setShowForm(false); + setForm({ alias: '', password: '' }); + setFile(null); + setPendingUpload(null); + load(); + } catch (err) { + const code = err.response?.data?.code; + // Show popup only if: no password was provided OR we're already in the modal and password was wrong + if (code === 'PASSWORD_REQUIRED' && !password) { + setPendingUpload({ file: uploadFile, alias: alias || uploadFile.name }); + } else if (code === 'PASSWORD_REQUIRED' && fromModal) { + toast.error('Contraseña incorrecta. Intenta de nuevo.'); + setPendingUpload({ file: uploadFile, alias: alias || uploadFile.name }); + } else { + toast.error(err.response?.data?.error || 'Error al subir certificado'); + } + } finally { + setUploading(false); + } + }; + + const handleUpload = async (e) => { + e.preventDefault(); + if (!file) { toast.error('Selecciona un archivo P12/PFX'); return; } + await doUpload(file, form.password, form.alias); + }; + + const handlePasswordModalConfirm = async (password) => { + if (!pendingUpload) return; + await doUpload(pendingUpload.file, password, pendingUpload.alias, true); + }; + + const handleDelete = async (id, alias) => { + if (!confirm(`¿Eliminar certificado "${alias}"?`)) return; + try { + await deleteCertificate(id); + toast.success('Certificado eliminado'); + load(); + } catch (err) { + toast.error(err.response?.data?.error || 'Error al eliminar'); + } + }; + + return ( +
+ {pendingUpload && ( + setPendingUpload(null)} + /> + )} + +
+
+

Certificados Digitales

+

{certs.length} certificado{certs.length !== 1 ? 's' : ''} registrado{certs.length !== 1 ? 's' : ''}

+
+
+ + +
+
+ + {showForm && ( +
+

Registrar certificado P12/PFX

+
+
+ + setFile(e.target.files[0] || null)} required /> +
+
+ + setForm(f => ({ ...f, alias: e.target.value }))} placeholder="Ej: Mi certificado BCE 2026" /> +
+
+ + setForm(f => ({ ...f, password: e.target.value }))} + placeholder="Dejar vacío si no tiene, o si prefieres ingresarla después" + /> + Si el certificado tiene contraseña y no la ingresas aquí, se te solicitará en el momento de la validación. +
+
+ + +
+
+
+ )} + + {loading ? ( +
+ ) : certs.length === 0 ? ( +
+ +

No tienes certificados. Agrega tu primer certificado P12/PFX vigente.

+
+ ) : ( +
+ {certs.map((cert) => { + const status = certStatus(cert.valid_to); + const chain = chainStatusMeta(cert.chain_status, cert.certificate_count); + return ( +
+
+ + {cert.alias} + {status && {status.label}} +
+
+ {cert.subject_cn &&

Sujeto: {cert.subject_cn}

} + {cert.issuer_cn &&

Emisor: {cert.issuer_cn}

} +

Válido: {formatDate(cert.valid_from)} — {formatDate(cert.valid_to)}

+ {typeof cert.certificate_count === 'number' &&

Certificados en P12: {cert.certificate_count}

} + {chain &&

Cadena: {chain.label}

} +
+ {chain && ( +
+ {chain.text} +
+ )} + {status?.cls === 'badge-rejected' && ( +
+ Este certificado está vencido y no puede usarse para firmar. +
+ )} +
+ Se solicitará contraseña al firmar + +
+
+ ); + })} +
+ )} +
+ ); +} diff --git a/frontend/src/pages/dashboard/DashboardPage.jsx b/frontend/src/pages/dashboard/DashboardPage.jsx new file mode 100644 index 0000000..2b8a8ce --- /dev/null +++ b/frontend/src/pages/dashboard/DashboardPage.jsx @@ -0,0 +1,124 @@ +import { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useAuth } from '../../contexts/AuthContext'; +import { listDocuments } from '../../api/documents'; +import { FileText, PenTool, CheckCircle2, CircleDot, Search } from 'lucide-react'; + +export default function DashboardPage() { + const { user } = useAuth(); + const navigate = useNavigate(); + const [stats, setStats] = useState({ docs: 0, certs: 0, signed: 0, pending: 0, review: 0 }); + const [recentDocs, setRecentDocs] = useState([]); + + useEffect(() => { + listDocuments({ limit: 100 }) + .then((docsRes) => { + const docs = docsRes.data.data.rows || []; + const pending = docs.filter((d) => d.status === 'pending').length; + const review = docs.filter((d) => d.status === 'partially_signed').length; + setStats({ + docs: docsRes.data.data.total || 0, + signed: docs.filter((d) => d.status === 'signed').length, + pending, + review, + }); + setRecentDocs(docs.slice(0, 5)); + }) + .catch(() => {}); + }, []); + + return ( +
+
+
+
+ Subir documento +
+
+
+
+ Revisar contenido +
+
+
+
3
+ Firmar +
+
+
+
4
+ Verificar identidad +
+
+
+
5
+ Completado +
+
+ +
+
navigate('/documents')}> +
Total documentos
+
{stats.docs}
+ +{Math.max(stats.docs - stats.signed, 0)} este mes +
+
navigate('/documents')}> +
Pendientes de firma
+
{stats.pending}
+ Requieren acción +
+
navigate('/signatures')}> +
Firmados
+
{stats.signed}
+ {stats.docs ? Math.round((stats.signed / stats.docs) * 100) : 0}% tasa de éxito +
+
navigate('/documents')}> +
En revisión
+
{stats.review}
+ En proceso +
+
+ +
+
+ Documentos recientes + navigate('/documents')}>Ver todos +
+
+ Documento + Firmante + Fecha + Estado + +
+ + {recentDocs.map((doc) => { + const status = doc.status === 'signed' + ? { cls: 'pill-signed', label: 'Firmado', icon: } + : doc.status === 'partially_signed' + ? { cls: 'pill-review', label: 'Revisión', icon: } + : { cls: 'pill-pending', label: 'Pendiente', icon: }; + return ( +
+
+
+ +
+
+
{doc.title}
+
{((doc.file_size || 0) / 1024).toFixed(1)} KB
+
+
+ {user?.name || '—'} + {doc.created_at ? new Date(doc.created_at).toLocaleDateString('es-EC') : '—'} + {status.label} + +
+ ); + })} +
+
+ ); +} diff --git a/frontend/src/pages/documents/DocumentsPage.jsx b/frontend/src/pages/documents/DocumentsPage.jsx new file mode 100644 index 0000000..01c4ed6 --- /dev/null +++ b/frontend/src/pages/documents/DocumentsPage.jsx @@ -0,0 +1,276 @@ +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 { Document, Page, pdfjs } from 'react-pdf'; +import 'react-pdf/dist/Page/AnnotationLayer.css'; +import 'react-pdf/dist/Page/TextLayer.css'; +import toast from 'react-hot-toast'; + +pdfjs.GlobalWorkerOptions.workerSrc = new URL( + 'pdfjs-dist/build/pdf.worker.min.mjs', + import.meta.url, +).toString(); + +const STATUS_LABELS = { + pending: { label: 'Pendiente', cls: 'badge-pending' }, + signed: { label: 'Firmado', cls: 'badge-signed' }, + partially_signed: { label: 'Parcial', cls: 'badge-partial' }, + rejected: { label: 'Rechazado', cls: 'badge-rejected' }, +}; + +export default function DocumentsPage() { + const navigate = useNavigate(); + const [docs, setDocs] = useState([]); + const [signedDocs, setSignedDocs] = useState([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(true); + const [uploading, setUploading] = useState(false); + const fileInputRef = useRef(); + + // Viewer state + const [viewerBlob, setViewerBlob] = useState(null); + const [viewerTitle, setViewerTitle] = useState(''); + const [numPages, setNumPages] = useState(null); + const [currentPage, setCurrentPage] = useState(1); + const [loadingViewer, setLoadingViewer] = useState(false); + + const loadDocs = () => { + setLoading(true); + 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)); + }; + + useEffect(() => { loadDocs(); }, []); + + 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); + toast.success('Documento subido correctamente'); + loadDocs(); + } catch (err) { + toast.error(err.response?.data?.error || 'Error al subir documento'); + } finally { + setUploading(false); + fileInputRef.current.value = ''; + } + }; + + const handleDelete = async (id, title) => { + if (!confirm(`¿Eliminar "${title}"?`)) return; + try { + await deleteDocument(id); + toast.success('Documento eliminado'); + loadDocs(); + } catch (err) { + toast.error(err.response?.data?.error || 'Error al eliminar'); + } + }; + + const handleDownload = async (id, filename) => { + try { + const { data } = await downloadDocument(id); + const url = URL.createObjectURL(data); + const a = document.createElement('a'); a.href = url; a.download = filename; a.click(); + URL.revokeObjectURL(url); + } catch { toast.error('Error al descargar'); } + }; + + const handleDownloadSigned = async (id, filename) => { + try { + const { data } = await downloadSigned(id); + const url = URL.createObjectURL(data); + const a = document.createElement('a'); a.href = url; a.download = filename; a.click(); + URL.revokeObjectURL(url); + } catch { toast.error('Error al descargar PDF firmado'); } + }; + + const handleView = async (id, title, source = 'original') => { + setLoadingViewer(true); + setViewerTitle(title); + setViewerBlob(null); + setCurrentPage(1); + setNumPages(null); + try { + const request = source === 'signed' ? downloadSigned(id) : downloadDocument(id); + const { data } = await request; + setViewerBlob(data); + } catch { toast.error('No se pudo cargar el documento'); } + finally { setLoadingViewer(false); } + }; + + const closeViewer = () => { setViewerBlob(null); setViewerTitle(''); }; + + return ( +
+ + {/* ── PDF Viewer Modal ── */} + {(viewerBlob || loadingViewer) && ( +
+
e.stopPropagation()}> +
+ {viewerTitle} +
+ {numPages && ( + <> + + {currentPage} / {numPages} + + + )} + +
+
+
+ {loadingViewer ? ( +
+ ) : ( + setNumPages(numPages)} + loading={
} + > + + + )} +
+
+
+ )} + +
+
+

Documentos

+

{total} documento{total !== 1 ? 's' : ''} original{total !== 1 ? 'es' : ''}

+
+
+ + + +
+
+ + {loading ? ( +
+ ) : ( +
+
+
+

Documentos originales para firmar

+
+ {docs.length === 0 ? ( +
+ +

No tienes documentos originales aún. Sube tu primer PDF.

+
+ ) : ( +
+ + + + + + + + + + + + {docs.map((doc) => { + const s = STATUS_LABELS[doc.status] || STATUS_LABELS.pending; + return ( + + + + + + + + ); + })} + +
NombreTamañoEstadoFechaAcciones
{doc.title}{(doc.file_size / 1024).toFixed(1)} KB{s.label}{new Date(doc.created_at).toLocaleDateString('es-EC')} + + + + +
+
+ )} +
+ +
+
+

Documentos firmados generados

+

{signedDocs.length} archivo{signedDocs.length !== 1 ? 's' : ''} firmado{signedDocs.length !== 1 ? 's' : ''}

+
+ {signedDocs.length === 0 ? ( +
+ +

Aún no has generado documentos firmados.

+
+ ) : ( +
+ + + + + + + + + + + + {signedDocs.map((doc) => ( + + + + + + + + ))} + +
PDF firmadoDocumento origenCertificadoFechaAcciones
{doc.signed_filename}{doc.document_title}{doc.certificate_alias}{new Date(doc.signed_at).toLocaleDateString('es-EC')} + + +
+
+ )} +
+
+ )} +
+ ); +} diff --git a/frontend/src/pages/signature-style/SignatureStylePage.jsx b/frontend/src/pages/signature-style/SignatureStylePage.jsx new file mode 100644 index 0000000..5f264bc --- /dev/null +++ b/frontend/src/pages/signature-style/SignatureStylePage.jsx @@ -0,0 +1,76 @@ +import { useEffect, useState } from 'react'; +import { Save, Palette } from 'lucide-react'; +import toast from 'react-hot-toast'; +import SignatureStylePicker from '../../components/ui/SignatureStylePicker'; +import { useAuth } from '../../contexts/AuthContext'; + +export default function SignatureStylePage() { + const { user, updateSignatureAppearancePreference } = useAuth(); + const [form, setForm] = useState({ + signatureStyle: user?.signaturePreferences?.signatureStyle || 'qr', + qrPosition: user?.signaturePreferences?.qrPosition || 'left', + qrBackground: user?.signaturePreferences?.qrBackground || 'transparent', + qrBorder: user?.signaturePreferences?.qrBorder || 'none', + signatureImageBase64: user?.signaturePreferences?.signatureImageBase64 || null, + }); + const [saving, setSaving] = useState(false); + + useEffect(() => { + setForm({ + signatureStyle: user?.signaturePreferences?.signatureStyle || 'qr', + qrPosition: user?.signaturePreferences?.qrPosition || 'left', + qrBackground: user?.signaturePreferences?.qrBackground || 'transparent', + qrBorder: user?.signaturePreferences?.qrBorder || 'none', + signatureImageBase64: user?.signaturePreferences?.signatureImageBase64 || null, + }); + }, [user?.signaturePreferences]); + + const handleSave = async () => { + setSaving(true); + try { + await updateSignatureAppearancePreference(form); + toast.success('Estilo de firma guardado'); + } catch { + toast.error('No se pudo guardar el estilo de firma'); + } finally { + setSaving(false); + } + }; + + return ( +
+
+
+

Estilo Firma

+

Configura cómo se verá tu firma visible en los documentos.

+
+
+ +
+
+ +
+
+ +

Este menú define el estilo visual que usará la pantalla Firmar por defecto.

+
+ + setForm((currentForm) => ({ ...currentForm, signatureStyle }))} + qrPosition={form.qrPosition} + onQrPositionChange={(qrPosition) => setForm((currentForm) => ({ ...currentForm, qrPosition }))} + qrBackground={form.qrBackground} + onQrBackgroundChange={(qrBackground) => setForm((currentForm) => ({ ...currentForm, qrBackground }))} + qrBorder={form.qrBorder} + onQrBorderChange={(qrBorder) => setForm((currentForm) => ({ ...currentForm, qrBorder }))} + signatureImage={form.signatureImageBase64} + onImageChange={(signatureImageBase64) => setForm((currentForm) => ({ ...currentForm, signatureImageBase64 }))} + signerName={user?.name || 'Firmante'} + /> +
+
+ ); +} diff --git a/frontend/src/pages/signatures/SignaturesPage.jsx b/frontend/src/pages/signatures/SignaturesPage.jsx new file mode 100644 index 0000000..1ac3c66 --- /dev/null +++ b/frontend/src/pages/signatures/SignaturesPage.jsx @@ -0,0 +1,300 @@ +import { useEffect, useState } from 'react'; +import { useSearchParams } from 'react-router-dom'; +import { listDocuments, downloadDocument } from '../../api/documents'; +import { listCertificates } from '../../api/certificates'; +import { signDocument, getSignatures, downloadSigned } from '../../api/signatures'; +import { PenTool, FileText, Shield, CheckCircle, MapPin, Download } from 'lucide-react'; +import toast from 'react-hot-toast'; +import PasswordModal from '../../components/ui/PasswordModal'; +import PdfSignaturePlacer from '../../components/ui/PdfSignaturePlacer'; +import { useAuth } from '../../contexts/AuthContext'; + +const STEPS = ['Selección', 'Posición', 'Firmar']; + +export default function SignaturesPage() { + const { user } = useAuth(); + const [searchParams] = useSearchParams(); + const [docs, setDocs] = useState([]); + const [certs, setCerts] = useState([]); + const [form, setForm] = useState({ + documentId: searchParams.get('docId') || '', + certificateId: '', + reason: 'Firmado digitalmente', + location: '', + signatureStyle: user?.signaturePreferences?.signatureStyle || 'qr', + qrPosition: user?.signaturePreferences?.qrPosition || 'left', + qrBackground: user?.signaturePreferences?.qrBackground || 'transparent', + qrBorder: user?.signaturePreferences?.qrBorder || 'none', + signatureImageBase64: user?.signaturePreferences?.signatureImageBase64 || null, + }); + const [step, setStep] = useState(0); // 0=select, 1=place, 2=sign + const [pdfBlob, setPdfBlob] = useState(null); + const [sigPlacement, setSigPlacement] = useState(null); // { page, rect } + const [signing, setSigning] = useState(false); + const [result, setResult] = useState(null); + const [sigs, setSigs] = useState([]); + const [pendingSign, setPendingSign] = useState(null); + + useEffect(() => { + Promise.all([ + listDocuments({ limit: 100 }).then(({ data }) => setDocs(data.data.rows.filter(d => d.status !== 'signed'))), + listCertificates().then(({ data }) => setCerts(data.data.filter(c => c.is_active))), + ]).catch(() => toast.error('Error al cargar datos')); + }, []); + + useEffect(() => { + if (form.documentId) { + getSignatures(form.documentId).then(({ data }) => setSigs(data.data || [])).catch(() => setSigs([])); + } + }, [form.documentId]); + + useEffect(() => { + if (!user?.signaturePreferences) return; + setForm((currentForm) => ({ + ...currentForm, + signatureStyle: user.signaturePreferences.signatureStyle || 'qr', + qrPosition: user.signaturePreferences.qrPosition || 'left', + qrBackground: user.signaturePreferences.qrBackground || 'transparent', + qrBorder: user.signaturePreferences.qrBorder || 'none', + signatureImageBase64: user.signaturePreferences.signatureImageBase64 || null, + })); + }, [user?.signaturePreferences]); + + const handleChange = (e) => setForm(f => ({ ...f, [e.target.name]: e.target.value })); + + // Step 0 → 1: load PDF for placement + const handleGoToPlace = async () => { + if (!form.documentId || !form.certificateId) { + toast.error('Selecciona documento y certificado'); return; + } + try { + const { data } = await downloadDocument(form.documentId); + setPdfBlob(data); + setSigPlacement(null); + setStep(1); + } catch { toast.error('No se pudo cargar el documento'); } + }; + + // Step 1 → 2: placement confirmed + const handlePlaced = (placement) => { + setSigPlacement(placement); + setStep(2); + }; + + const doSign = async (certPassword = '', fromModal = false) => { + if (!form.documentId || !form.certificateId) { + toast.error('Faltan datos de documento o certificado. Vuelve al paso 1.'); + setStep(0); + return; + } + setSigning(true); + const payload = { + documentId: Number(form.documentId), + certificateId: Number(form.certificateId), + certPassword, + reason: form.reason, + location: form.location, + page: sigPlacement?.page ?? 0, + rect: sigPlacement?.rect ?? null, + signatureStyle: form.signatureStyle, + qrPosition: form.qrPosition, + qrBackground: form.qrBackground, + qrBorder: form.qrBorder, + signatureImageBase64: form.signatureImageBase64, + }; + try { + const { data } = await signDocument(payload); + setResult(data.data); + toast.success('¡Documento firmado exitosamente!'); + setDocs(prev => prev.filter(d => d.id !== payload.documentId)); + setForm(f => ({ ...f, documentId: '', certificateId: '' })); + setSigPlacement(null); + setPdfBlob(null); + setStep(0); + setPendingSign(null); + } catch (err) { + const code = err.response?.data?.code; + if (code === 'CERT_PASSWORD_REQUIRED' && !fromModal) { + setPendingSign(payload); + } else if (code === 'CERT_PASSWORD_REQUIRED' && fromModal) { + toast.error('Contraseña incorrecta. Intenta de nuevo.'); + setPendingSign(payload); + } else { + toast.error(err.response?.data?.error || 'Error al firmar'); + } + } finally { setSigning(false); } + }; + + const selectedCert = certs.find(c => c.id === Number(form.certificateId)); + const selectedDoc = docs.find(d => d.id === Number(form.documentId)); + + return ( +
+ {pendingSign && ( + doSign(pw, true)} + onCancel={() => setPendingSign(null)} + /> + )} + +
+

Firmar Documento

+

Firma digital con certificado P12 — firma visible en el PDF

+
+ + {/* Steps indicator */} +
+ {STEPS.map((label, i) => ( +
+
{i < step ? '✓' : i + 1}
+ {label} +
+ ))} +
+ + {/* ── Step 0: Selección ── */} + {step === 0 && ( +
+
+
+ + + {docs.length === 0 && No hay documentos pendientes.} +
+
+ + + {certs.length === 0 && No tienes certificados activos.} +
+
+ + +
+
+ + +
+
+

Estilo aplicado

+

La firma usará tu configuración guardada en el menú Estilo Firma.

+

Tipo: {form.signatureStyle}

+ {form.signatureStyle === 'qr' &&

Posición QR: {form.qrPosition === 'left' ? 'Izquierda' : 'Derecha'}

} + {form.signatureStyle === 'qr' &&

Fondo: {form.qrBackground === 'transparent' ? 'Transparente' : 'Suave'}

} + {form.signatureStyle === 'qr' &&

Borde: {form.qrBorder === 'none' ? 'Sin borde' : 'Con borde'}

} +
+ +
+ +
+ {result && ( +
+ +

¡Firma aplicada!

+

Firma visible + firma criptográfica válida.

+ {result.signedFilename} + +
+ )} + {sigs.length > 0 && ( +
+

Firmas del documento

+ {sigs.map(sig => ( +
+ +
+ {sig.signer_name} +

{sig.signer_email}

+ {new Date(sig.signed_at).toLocaleString('es-EC')} +
+
+ ))} +
+ )} +
+

Proceso de firma

+
    +
  1. Selecciona documento y certificado
  2. +
  3. Elige dónde colocar la firma en el PDF
  4. +
  5. Confirma — se pedirá contraseña si es necesario
  6. +
  7. Descarga el PDF firmado con firma visible
  8. +
+
+
+
+ )} + + {/* ── Step 1: Posicionamiento ── */} + {step === 1 && pdfBlob && ( +
+
+ +

+ Haz clic donde quieres que aparezca la firma en {selectedDoc?.title} +

+
+ +
+ )} + + {/* ── Step 2: Confirmar y firmar ── */} + {step === 2 && ( +
+
+ +

Posición confirmada — listo para firmar

+
+
+

Documento: {selectedDoc?.title}

+

Certificado: {selectedCert?.alias}

+

Razón: {form.reason}

+ {form.location &&

Ubicación: {form.location}

} +

Estilo visual: {form.signatureStyle}

+ {form.signatureStyle === 'qr' &&

Posición QR: {form.qrPosition === 'left' ? 'Izquierda' : 'Derecha'}

} + {form.signatureStyle === 'qr' &&

Fondo: {form.qrBackground === 'transparent' ? 'Transparente' : 'Suave'}

} + {form.signatureStyle === 'qr' &&

Borde: {form.qrBorder === 'none' ? 'Sin borde' : 'Con borde'}

} +

Página: {(sigPlacement?.page ?? 0) + 1}

+
+
+ + +
+
+ )} +
+ ); +} diff --git a/frontend/src/pages/verify/VerifySignaturePage.jsx b/frontend/src/pages/verify/VerifySignaturePage.jsx new file mode 100644 index 0000000..2fd84d1 --- /dev/null +++ b/frontend/src/pages/verify/VerifySignaturePage.jsx @@ -0,0 +1,98 @@ +import { useEffect, useState } from 'react'; +import { useParams, Link } from 'react-router-dom'; +import { ShieldCheck, ShieldAlert, FileText, UserRound, CalendarDays, MapPin } from 'lucide-react'; +import { verifySignature } from '../../api/public'; + +export default function VerifySignaturePage() { + const { code } = useParams(); + const [loading, setLoading] = useState(true); + const [result, setResult] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + setLoading(true); + verifySignature(code) + .then(({ data }) => { + setResult(data.data); + setError(null); + }) + .catch((err) => { + setError(err.response?.data?.error || 'No se pudo verificar la firma.'); + setResult(null); + }) + .finally(() => setLoading(false)); + }, [code]); + + return ( +
+
+ {loading ? ( +
+ ) : error ? ( + <> +
+ +
+

Verificación no disponible

+

{error}

+
+
+ Volver + + ) : ( + <> +
+ {result.isValid ? : } +
+

{result.isValid ? 'Firma electrónica válida' : 'Firma electrónica no válida'}

+

+ {result.isValid + ? 'La integridad del documento coincide con el registro de LinkedSign.' + : 'La integridad del documento no coincide con el registro de LinkedSign.'} +

+
+
+ +
+
+ +
+ Documento + {result.documentTitle} +
+
+
+ +
+ Firmante + {result.certificateSubjectCn || result.signerName} +
+
+
+ +
+ Fecha de firma + {new Date(result.signedAt).toLocaleString('es-EC')} +
+
+
+ +
+ Ubicación + {result.location || '—'} +
+
+
+ +
+

Motivo: {result.reason || 'Firmado digitalmente'}

+

Emisor del certificado: {result.certificateIssuerCn || '—'}

+

Código de verificación: {result.verificationCode}

+

Archivo firmado: {result.signedFilename}

+
+ + )} +
+
+ ); +} diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..f5855bc --- /dev/null +++ b/frontend/vite.config.js @@ -0,0 +1,11 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [react()], + server: { + host: '0.0.0.0', + port: 5173, + }, +})