commit 1074c7c9d2b3bd5b249032d6ced49bca8f77247f Author: Milton Gonzalez Date: Mon Apr 6 20:33:59 2026 -0500 Initial LinkedSign platform 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 0000000..a4b76b9 Binary files /dev/null and b/android/gradle/wrapper/gradle-wrapper.jar differ 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 0000000..cc51a3d Binary files /dev/null and b/frontend/src/assets/hero.png differ 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, + }, +})