Initial LinkedSign platform
This commit is contained in:
+81
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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).
|
||||||
@@ -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'
|
||||||
|
}
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
# LinkedSign Android native app
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:allowBackup="true"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
|
android:usesCleartextTraffic="true"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:theme="@style/Theme.LinkedSign">
|
||||||
|
<provider
|
||||||
|
android:name="androidx.core.content.FileProvider"
|
||||||
|
android:authorities="net.linkedcorp.linkedsign.fileprovider"
|
||||||
|
android:exported="false"
|
||||||
|
android:grantUriPermissions="true">
|
||||||
|
<meta-data
|
||||||
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
|
android:resource="@xml/file_paths" />
|
||||||
|
</provider>
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:theme="@style/Theme.LinkedSign">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
</application>
|
||||||
|
|
||||||
|
</manifest>
|
||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<LoginData>
|
||||||
|
|
||||||
|
@POST("auth/register")
|
||||||
|
suspend fun register(@Body body: RegisterRequest): ApiEnvelope<RegisterData>
|
||||||
|
|
||||||
|
@POST("auth/refresh")
|
||||||
|
suspend fun refresh(@Body body: RefreshRequest): ApiEnvelope<RefreshData>
|
||||||
|
|
||||||
|
@POST("auth/logout")
|
||||||
|
suspend fun logout(@Body body: RefreshRequest): ApiEnvelope<SimpleMessageData>
|
||||||
|
|
||||||
|
@GET("auth/me")
|
||||||
|
suspend fun me(@Header("Authorization") authorization: String): ApiEnvelope<MeData>
|
||||||
|
|
||||||
|
@PATCH("auth/change-password")
|
||||||
|
suspend fun changePassword(
|
||||||
|
@Header("Authorization") authorization: String,
|
||||||
|
@Body body: ChangePasswordRequest
|
||||||
|
): ApiEnvelope<SimpleMessageData>
|
||||||
|
|
||||||
|
@GET("documents")
|
||||||
|
suspend fun listDocuments(
|
||||||
|
@Header("Authorization") authorization: String,
|
||||||
|
@Query("limit") limit: Int = 100
|
||||||
|
): ApiEnvelope<DocumentListData>
|
||||||
|
|
||||||
|
@Multipart
|
||||||
|
@POST("documents")
|
||||||
|
suspend fun uploadDocument(
|
||||||
|
@Header("Authorization") authorization: String,
|
||||||
|
@Part file: MultipartBody.Part
|
||||||
|
): ApiEnvelope<DocumentItem>
|
||||||
|
|
||||||
|
@DELETE("documents/{id}")
|
||||||
|
suspend fun deleteDocument(
|
||||||
|
@Header("Authorization") authorization: String,
|
||||||
|
@retrofit2.http.Path("id") id: Int
|
||||||
|
): ApiEnvelope<SimpleMessageData>
|
||||||
|
|
||||||
|
@GET("certificates")
|
||||||
|
suspend fun listCertificates(
|
||||||
|
@Header("Authorization") authorization: String
|
||||||
|
): ApiEnvelope<List<CertificateItem>>
|
||||||
|
|
||||||
|
@Multipart
|
||||||
|
@POST("certificates")
|
||||||
|
suspend fun uploadCertificate(
|
||||||
|
@Header("Authorization") authorization: String,
|
||||||
|
@Part file: MultipartBody.Part,
|
||||||
|
@Part("alias") alias: RequestBody,
|
||||||
|
@Part("password") password: RequestBody
|
||||||
|
): ApiEnvelope<CertificateItem>
|
||||||
|
|
||||||
|
@GET("signatures/mine")
|
||||||
|
suspend fun listSignedDocuments(
|
||||||
|
@Header("Authorization") authorization: String
|
||||||
|
): ApiEnvelope<List<SignedDocumentItem>>
|
||||||
|
|
||||||
|
@DELETE("signatures/{id}")
|
||||||
|
suspend fun deleteSignedDocument(
|
||||||
|
@Header("Authorization") authorization: String,
|
||||||
|
@retrofit2.http.Path("id") id: Int
|
||||||
|
): ApiEnvelope<SimpleMessageData>
|
||||||
|
|
||||||
|
@POST("signatures/sign")
|
||||||
|
suspend fun signDocument(
|
||||||
|
@Header("Authorization") authorization: String,
|
||||||
|
@Body body: SignRequest
|
||||||
|
): ApiEnvelope<SignData>
|
||||||
|
}
|
||||||
@@ -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<SessionSnapshot, CacheSnapshot> {
|
||||||
|
return sessionStore.load() to localCache.loadSnapshot()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun bootstrap(): Pair<SessionSnapshot, CacheSnapshot> {
|
||||||
|
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 <T> 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 <T> 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 <T> unwrap(response: ApiEnvelope<T>): 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<DocumentItem>) = replaceTable("documents_cache", rows, documentAdapter) { it.id }
|
||||||
|
|
||||||
|
fun replaceCertificates(rows: List<CertificateItem>) = replaceTable("certificates_cache", rows, certificateAdapter) { it.id }
|
||||||
|
|
||||||
|
fun replaceSignedDocuments(rows: List<SignedDocumentItem>) = 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 <T> replaceTable(
|
||||||
|
table: String,
|
||||||
|
rows: List<T>,
|
||||||
|
adapter: com.squareup.moshi.JsonAdapter<T>,
|
||||||
|
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 <T> querySinglePayload(
|
||||||
|
db: SQLiteDatabase,
|
||||||
|
table: String,
|
||||||
|
adapter: com.squareup.moshi.JsonAdapter<T>
|
||||||
|
): T? = db.rawQuery("SELECT payload FROM $table LIMIT 1", null).use { cursor ->
|
||||||
|
if (!cursor.moveToFirst()) return null
|
||||||
|
adapter.fromJson(cursor.getString(0))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun <T> queryAllPayloads(
|
||||||
|
db: SQLiteDatabase,
|
||||||
|
table: String,
|
||||||
|
adapter: com.squareup.moshi.JsonAdapter<T>
|
||||||
|
): List<T> = db.rawQuery("SELECT payload FROM $table ORDER BY id DESC", null).use { cursor ->
|
||||||
|
val result = mutableListOf<T>()
|
||||||
|
while (cursor.moveToNext()) {
|
||||||
|
adapter.fromJson(cursor.getString(0))?.let(result::add)
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
package net.linkedcorp.linkedsign.data
|
||||||
|
|
||||||
|
import com.squareup.moshi.Json
|
||||||
|
|
||||||
|
data class ApiEnvelope<T>(
|
||||||
|
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<DocumentItem> = 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<DocumentItem> = emptyList(),
|
||||||
|
val certificates: List<CertificateItem> = emptyList(),
|
||||||
|
val signedDocuments: List<SignedDocumentItem> = emptyList(),
|
||||||
|
val lastSyncAt: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
class AppException(
|
||||||
|
override val message: String,
|
||||||
|
val code: String? = null
|
||||||
|
) : Exception(message)
|
||||||
@@ -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<String, String>? {
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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<net.linkedcorp.linkedsign.data.DocumentItem> = emptyList(),
|
||||||
|
val certificates: List<net.linkedcorp.linkedsign.data.CertificateItem> = emptyList(),
|
||||||
|
val signedDocuments: List<net.linkedcorp.linkedsign.data.SignedDocumentItem> = 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.")
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
|
)
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="108"
|
||||||
|
android:viewportHeight="108">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:pathData="M54,18c-11.2,8.4 -22.9,12.6 -35,12.6v21.8c0,24.5 14.8,34.4 35,40.6c20.2,-6.2 35,-16.1 35,-40.6V30.6C76.9,30.6 65.2,26.4 54,18z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#4F46E5"
|
||||||
|
android:pathData="M40,38h12c7.9,0 14,5.3 14,12.5S59.9,63 52,63h-4v8h-8V38zm8,7v11h3.7c3.7,0 6.1,-2.1 6.1,-5.5c0,-3.5 -2.4,-5.5 -6.1,-5.5H48z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#0F172A"
|
||||||
|
android:pathData="M38,76h32v4H38z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/ic_launcher_background" />
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
</adaptive-icon>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/ic_launcher_background" />
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
</adaptive-icon>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<resources>
|
||||||
|
<color name="purple_primary">#4F46E5</color>
|
||||||
|
<color name="dark_surface">#111827</color>
|
||||||
|
<color name="background_soft">#F3F4F6</color>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="ic_launcher_background">#111827</color>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<resources>
|
||||||
|
<string name="app_name">LinkedSign</string>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
<style name="Theme.LinkedSign" parent="Theme.AppCompat.Light.NoActionBar">
|
||||||
|
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||||
|
<item name="android:navigationBarColor">@color/background_soft</item>
|
||||||
|
<item name="android:windowLightStatusBar">true</item>
|
||||||
|
<item name="android:windowLightNavigationBar">true</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<cache-path
|
||||||
|
name="cache_files"
|
||||||
|
path="." />
|
||||||
|
<files-path
|
||||||
|
name="files"
|
||||||
|
path="." />
|
||||||
|
</paths>
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||||
|
android.useAndroidX=true
|
||||||
|
android.nonTransitiveRClass=true
|
||||||
|
kotlin.code.style=official
|
||||||
BIN
Binary file not shown.
@@ -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
|
||||||
+252
@@ -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" "$@"
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
rootProject.name = "LinkedSign"
|
||||||
|
include ':app'
|
||||||
@@ -0,0 +1,457 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="es">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>FirmaDoc — Prototipo Android</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&family=Roboto+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body {
|
||||||
|
font-family: 'Roboto', sans-serif;
|
||||||
|
background: #F0F4FF;
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 2rem 1rem;
|
||||||
|
color: #1A1A2E;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #1565C0;
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
}
|
||||||
|
.subtitle {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #6B7A99;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
.screens-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: 1.5rem;
|
||||||
|
max-width: 1100px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
/* Phone card */
|
||||||
|
.phone {
|
||||||
|
background: #ffffff;
|
||||||
|
border: 0.5px solid #C5D0E8;
|
||||||
|
border-radius: 16px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 4px 24px rgba(21,101,192,0.08);
|
||||||
|
}
|
||||||
|
.status-bar {
|
||||||
|
padding: 6px 12px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.status-bar span { color: #fff; font-size: 11px; font-weight: 500; }
|
||||||
|
.status-icons { display: flex; gap: 4px; align-items: center; }
|
||||||
|
.app-bar {
|
||||||
|
padding: 8px 14px 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.app-bar-title { color: #fff; font-size: 13px; font-weight: 500; }
|
||||||
|
.app-bar-sub { color: #90CAF9; font-size: 10px; }
|
||||||
|
.body-area { padding: 12px; background: #F0F4FF; }
|
||||||
|
.section-label {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #6B7A99;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .5px;
|
||||||
|
}
|
||||||
|
.doc-item {
|
||||||
|
background: #fff;
|
||||||
|
border: 0.5px solid #C5D0E8;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 10px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.doc-icon {
|
||||||
|
width: 34px; height: 40px;
|
||||||
|
border-radius: 4px;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.doc-name { font-size: 11px; font-weight: 500; color: #1A1A2E; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.doc-meta { font-size: 10px; color: #6B7A99; }
|
||||||
|
.dot-pending { width: 8px; height: 8px; border-radius: 50%; background: #E65100; flex-shrink: 0; }
|
||||||
|
.fab {
|
||||||
|
background: #1565C0;
|
||||||
|
border-radius: 24px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
display: flex; align-items: center; justify-content: center; gap: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
margin: 0 12px 10px;
|
||||||
|
}
|
||||||
|
.fab span { color: #fff; font-size: 12px; font-weight: 500; }
|
||||||
|
.nav-bar {
|
||||||
|
background: #fff;
|
||||||
|
border-top: 0.5px solid #C5D0E8;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-around;
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
.nav-item { display: flex; flex-direction: column; align-items: center; gap: 3px; }
|
||||||
|
.nav-item span { font-size: 9px; }
|
||||||
|
/* Tabs */
|
||||||
|
.tabs { display: flex; border-bottom: 0.5px solid #C5D0E8; }
|
||||||
|
.tab { flex: 1; padding: 9px 0; text-align: center; font-size: 11px; }
|
||||||
|
.tab.active { color: #1565C0; border-bottom: 2px solid #1565C0; font-weight: 500; }
|
||||||
|
.tab.inactive { color: #6B7A99; }
|
||||||
|
/* Canvas area */
|
||||||
|
.sig-canvas {
|
||||||
|
background: #fff;
|
||||||
|
border: 0.5px solid #C5D0E8;
|
||||||
|
border-radius: 10px;
|
||||||
|
height: 140px;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.sig-canvas-hint {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%; left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
font-size: 10px;
|
||||||
|
color: #C5D0E8;
|
||||||
|
}
|
||||||
|
/* Certificate */
|
||||||
|
.cert-card {
|
||||||
|
background: #fff;
|
||||||
|
border: 0.5px solid #C5D0E8;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 12px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.cert-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.cert-key { font-size: 10px; color: #6B7A99; }
|
||||||
|
.cert-val { font-size: 10px; font-weight: 500; color: #1A1A2E; }
|
||||||
|
/* Action buttons */
|
||||||
|
.action-row { display: flex; gap: 8px; }
|
||||||
|
.action-btn {
|
||||||
|
flex: 1;
|
||||||
|
background: #fff;
|
||||||
|
border: 0.5px solid #C5D0E8;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 9px;
|
||||||
|
display: flex; align-items: center; justify-content: center; gap: 5px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.action-btn span { font-size: 10px; color: #1565C0; font-weight: 500; }
|
||||||
|
.action-btn.primary { background: #1565C0; }
|
||||||
|
.action-btn.primary span { color: #fff; }
|
||||||
|
/* Confirm btn */
|
||||||
|
.confirm-btn {
|
||||||
|
background: #1565C0;
|
||||||
|
border-radius: 24px;
|
||||||
|
padding: 10px;
|
||||||
|
display: flex; align-items: center; justify-content: center; gap: 6px;
|
||||||
|
margin: 0 12px 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.confirm-btn span { color: #fff; font-size: 12px; font-weight: 500; }
|
||||||
|
/* Flow bar */
|
||||||
|
.flow-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 2rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: 0 1rem;
|
||||||
|
}
|
||||||
|
.flow-step {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #6B7A99;
|
||||||
|
background: #fff;
|
||||||
|
border: 0.5px solid #C5D0E8;
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
}
|
||||||
|
.flow-step.success { color: #2E7D32; background: #E8F5E9; border-color: #A5D6A7; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<h1>FirmaDoc</h1>
|
||||||
|
<p class="subtitle">Prototipo de sistema Android para firma electrónica de documentos</p>
|
||||||
|
|
||||||
|
<div class="screens-grid">
|
||||||
|
|
||||||
|
<!-- Screen 1: Home -->
|
||||||
|
<div class="phone">
|
||||||
|
<div class="status-bar" style="background:#1565C0;">
|
||||||
|
<span>9:41</span>
|
||||||
|
<div class="status-icons">
|
||||||
|
<svg width="12" height="10" viewBox="0 0 12 10"><rect x="0" y="3" width="2" height="7" fill="#fff"/><rect x="3" y="2" width="2" height="8" fill="#fff"/><rect x="6" y="1" width="2" height="9" fill="#fff"/><rect x="9" y="0" width="2" height="10" fill="#fff"/></svg>
|
||||||
|
<svg width="16" height="10" viewBox="0 0 16 10"><rect x="0" y="1" width="13" height="8" rx="2" stroke="#fff" stroke-width="1" fill="none"/><rect x="13.5" y="3" width="2" height="4" rx="1" fill="#fff"/><rect x="1" y="2.5" width="9" height="5" rx="1" fill="#fff"/></svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="app-bar" style="background:#1565C0;">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8l-6-6z" fill="none" stroke="#fff" stroke-width="2"/><polyline points="14 2 14 8 20 8" stroke="#fff" stroke-width="2"/><path d="M9 13l2 2 4-4" stroke="#00BCD4" stroke-width="2" stroke-linecap="round"/></svg>
|
||||||
|
<div>
|
||||||
|
<div class="app-bar-title">FirmaDoc</div>
|
||||||
|
<div class="app-bar-sub">Firma electrónica</div>
|
||||||
|
</div>
|
||||||
|
<div style="margin-left:auto;">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24"><circle cx="11" cy="11" r="8" stroke="#fff" stroke-width="2" fill="none"/><line x1="21" y1="21" x2="16.65" y2="16.65" stroke="#fff" stroke-width="2"/></svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="body-area">
|
||||||
|
<div class="section-label">Mis documentos</div>
|
||||||
|
<div class="doc-item">
|
||||||
|
<div class="doc-icon" style="background:#E3F2FD;">
|
||||||
|
<svg width="16" height="20" viewBox="0 0 16 20"><rect width="16" height="20" rx="2" fill="#1565C0"/><text x="2" y="14" font-size="7" fill="white" font-weight="bold">PDF</text></svg>
|
||||||
|
</div>
|
||||||
|
<div style="flex:1;min-width:0;">
|
||||||
|
<div class="doc-name">Contrato_servicios.pdf</div>
|
||||||
|
<div class="doc-meta">2.4 MB · Pendiente</div>
|
||||||
|
</div>
|
||||||
|
<div class="dot-pending"></div>
|
||||||
|
</div>
|
||||||
|
<div class="doc-item">
|
||||||
|
<div class="doc-icon" style="background:#E8F5E9;">
|
||||||
|
<svg width="16" height="20" viewBox="0 0 16 20"><rect width="16" height="20" rx="2" fill="#2E7D32"/><text x="2" y="14" font-size="7" fill="white" font-weight="bold">PDF</text></svg>
|
||||||
|
</div>
|
||||||
|
<div style="flex:1;min-width:0;">
|
||||||
|
<div class="doc-name">Acuerdo_confidencial.pdf</div>
|
||||||
|
<div class="doc-meta" style="color:#2E7D32;">1.1 MB · Firmado</div>
|
||||||
|
</div>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24"><path d="M22 11.08V12a10 10 0 11-5.93-9.14" stroke="#2E7D32" stroke-width="2.5" fill="none"/><polyline points="22 4 12 14.01 9 11.01" stroke="#2E7D32" stroke-width="2.5" fill="none"/></svg>
|
||||||
|
</div>
|
||||||
|
<div class="doc-item">
|
||||||
|
<div class="doc-icon" style="background:#E3F2FD;">
|
||||||
|
<svg width="16" height="20" viewBox="0 0 16 20"><rect width="16" height="20" rx="2" fill="#1565C0"/><text x="2" y="14" font-size="7" fill="white" font-weight="bold">PDF</text></svg>
|
||||||
|
</div>
|
||||||
|
<div style="flex:1;min-width:0;">
|
||||||
|
<div class="doc-name">Propuesta_2024.pdf</div>
|
||||||
|
<div class="doc-meta">890 KB · Pendiente</div>
|
||||||
|
</div>
|
||||||
|
<div class="dot-pending"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="fab">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19" stroke="#fff" stroke-width="2.5"/><line x1="5" y1="12" x2="19" y2="12" stroke="#fff" stroke-width="2.5"/></svg>
|
||||||
|
<span>Subir documento</span>
|
||||||
|
</div>
|
||||||
|
<div class="nav-bar">
|
||||||
|
<div class="nav-item">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24"><rect x="3" y="3" width="7" height="9" rx="1" fill="#1565C0"/><rect x="14" y="3" width="7" height="5" rx="1" fill="none" stroke="#6B7A99" stroke-width="1.5"/><rect x="3" y="15" width="7" height="5" rx="1" fill="none" stroke="#6B7A99" stroke-width="1.5"/><rect x="14" y="11" width="7" height="9" rx="1" fill="none" stroke="#6B7A99" stroke-width="1.5"/></svg>
|
||||||
|
<span style="color:#1565C0;font-weight:500;">Inicio</span>
|
||||||
|
</div>
|
||||||
|
<div class="nav-item">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8l-6-6z" fill="none" stroke="#6B7A99" stroke-width="1.5"/></svg>
|
||||||
|
<span style="color:#6B7A99;">Docs</span>
|
||||||
|
</div>
|
||||||
|
<div class="nav-item">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24"><path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2" stroke="#6B7A99" stroke-width="1.5" fill="none"/><circle cx="12" cy="7" r="4" stroke="#6B7A99" stroke-width="1.5" fill="none"/></svg>
|
||||||
|
<span style="color:#6B7A99;">Perfil</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Screen 2: Vista previa -->
|
||||||
|
<div class="phone">
|
||||||
|
<div class="status-bar" style="background:#1565C0;">
|
||||||
|
<span>9:41</span>
|
||||||
|
<div class="status-icons">
|
||||||
|
<svg width="16" height="10" viewBox="0 0 16 10"><rect x="0" y="1" width="13" height="8" rx="2" stroke="#fff" stroke-width="1" fill="none"/><rect x="13.5" y="3" width="2" height="4" rx="1" fill="#fff"/><rect x="1" y="2.5" width="9" height="5" rx="1" fill="#fff"/></svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="app-bar" style="background:#1565C0;">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24"><polyline points="15 18 9 12 15 6" stroke="#fff" stroke-width="2" fill="none"/></svg>
|
||||||
|
<div style="flex:1;">
|
||||||
|
<div class="app-bar-title">Contrato_servicios.pdf</div>
|
||||||
|
<div class="app-bar-sub">Vista previa</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="background:#9E9E9E;padding:8px;min-height:220px;">
|
||||||
|
<div style="background:#fff;border-radius:4px;padding:12px;min-height:200px;">
|
||||||
|
<div style="width:60%;height:7px;background:#1A1A2E;border-radius:2px;margin-bottom:6px;opacity:.8;"></div>
|
||||||
|
<div style="width:40%;height:5px;background:#6B7A99;border-radius:2px;margin-bottom:12px;opacity:.5;"></div>
|
||||||
|
<div style="width:100%;height:4px;background:#C5D0E8;border-radius:2px;margin-bottom:5px;"></div>
|
||||||
|
<div style="width:95%;height:4px;background:#C5D0E8;border-radius:2px;margin-bottom:5px;"></div>
|
||||||
|
<div style="width:88%;height:4px;background:#C5D0E8;border-radius:2px;margin-bottom:10px;"></div>
|
||||||
|
<div style="width:100%;height:4px;background:#C5D0E8;border-radius:2px;margin-bottom:5px;"></div>
|
||||||
|
<div style="width:97%;height:4px;background:#C5D0E8;border-radius:2px;margin-bottom:5px;"></div>
|
||||||
|
<div style="width:70%;height:4px;background:#C5D0E8;border-radius:2px;margin-bottom:14px;"></div>
|
||||||
|
<div style="border:1.5px dashed #1565C0;border-radius:6px;padding:10px;background:#F0F4FF;display:flex;flex-direction:column;align-items:center;gap:5px;">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24"><path d="M12 20h9" stroke="#1565C0" stroke-width="2"/><path d="M16.5 3.5a2.121 2.121 0 013 3L7 19l-4 1 1-4L16.5 3.5z" stroke="#1565C0" stroke-width="2" fill="none"/></svg>
|
||||||
|
<span style="font-size:9px;color:#1565C0;font-weight:500;">Zona de firma</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="padding:12px;background:#F0F4FF;">
|
||||||
|
<div style="background:#1565C0;border-radius:24px;padding:11px;display:flex;align-items:center;justify-content:center;gap:8px;cursor:pointer;">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24"><path d="M12 20h9" stroke="#fff" stroke-width="2"/><path d="M16.5 3.5a2.121 2.121 0 013 3L7 19l-4 1 1-4L16.5 3.5z" stroke="#fff" stroke-width="2" fill="none"/></svg>
|
||||||
|
<span style="color:#fff;font-size:12px;font-weight:500;">Firmar este documento</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Screen 3: Dibujar firma -->
|
||||||
|
<div class="phone">
|
||||||
|
<div class="status-bar" style="background:#1565C0;">
|
||||||
|
<span>9:41</span>
|
||||||
|
<div class="status-icons">
|
||||||
|
<svg width="16" height="10" viewBox="0 0 16 10"><rect x="0" y="1" width="13" height="8" rx="2" stroke="#fff" stroke-width="1" fill="none"/><rect x="13.5" y="3" width="2" height="4" rx="1" fill="#fff"/><rect x="1" y="2.5" width="9" height="5" rx="1" fill="#fff"/></svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="app-bar" style="background:#1565C0;">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24"><polyline points="15 18 9 12 15 6" stroke="#fff" stroke-width="2" fill="none"/></svg>
|
||||||
|
<div style="color:#fff;font-size:12px;font-weight:500;">Dibujar firma</div>
|
||||||
|
</div>
|
||||||
|
<div class="tabs">
|
||||||
|
<div class="tab active">Dibujar</div>
|
||||||
|
<div class="tab inactive">Escribir</div>
|
||||||
|
<div class="tab inactive">Imagen</div>
|
||||||
|
</div>
|
||||||
|
<div class="body-area">
|
||||||
|
<div class="sig-canvas">
|
||||||
|
<span class="sig-canvas-hint">Firme aquí con su dedo</span>
|
||||||
|
<svg width="100%" height="100%" viewBox="0 0 200 140" preserveAspectRatio="none" style="position:absolute;top:0;left:0;">
|
||||||
|
<path d="M20,100 C30,80 40,60 55,75 C65,85 70,95 80,70 C88,52 95,45 110,60 C120,70 125,78 135,55 C145,35 155,40 165,60 C170,70 175,78 180,65" stroke="#1A1A2E" stroke-width="2.5" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
<div style="position:absolute;bottom:28px;left:12px;right:12px;border-top:1px dashed #C5D0E8;"></div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;">
|
||||||
|
<div style="display:flex;gap:6px;">
|
||||||
|
<div style="width:18px;height:18px;border-radius:50%;background:#1A1A2E;border:2px solid #C5D0E8;cursor:pointer;"></div>
|
||||||
|
<div style="width:18px;height:18px;border-radius:50%;background:#1565C0;border:2px solid #C5D0E8;cursor:pointer;"></div>
|
||||||
|
<div style="width:18px;height:18px;border-radius:50%;background:#2E7D32;border:2px solid #C5D0E8;cursor:pointer;"></div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:6px;align-items:center;">
|
||||||
|
<span style="font-size:10px;color:#6B7A99;">Grosor</span>
|
||||||
|
<div style="width:16px;height:3px;background:#6B7A99;border-radius:2px;"></div>
|
||||||
|
<div style="width:16px;height:5px;background:#1A1A2E;border-radius:2px;"></div>
|
||||||
|
<div style="width:16px;height:7px;background:#C5D0E8;border-radius:2px;"></div>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:11px;color:#1565C0;font-weight:500;cursor:pointer;">Limpiar</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="confirm-btn">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12" stroke="#fff" stroke-width="2.5" fill="none"/></svg>
|
||||||
|
<span>Confirmar firma</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Screen 4: Biometría -->
|
||||||
|
<div class="phone">
|
||||||
|
<div class="status-bar" style="background:#0D47A1;">
|
||||||
|
<span>9:41</span>
|
||||||
|
<div class="status-icons">
|
||||||
|
<svg width="16" height="10" viewBox="0 0 16 10"><rect x="0" y="1" width="13" height="8" rx="2" stroke="#fff" stroke-width="1" fill="none"/><rect x="13.5" y="3" width="2" height="4" rx="1" fill="#fff"/><rect x="1" y="2.5" width="9" height="5" rx="1" fill="#fff"/></svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="app-bar" style="background:#0D47A1;">
|
||||||
|
<div class="app-bar-title">Verificar identidad</div>
|
||||||
|
</div>
|
||||||
|
<div class="body-area">
|
||||||
|
<div style="background:#fff;border:0.5px solid #C5D0E8;border-radius:12px;padding:16px;text-align:center;margin-bottom:10px;">
|
||||||
|
<div style="width:64px;height:64px;margin:0 auto 12px;">
|
||||||
|
<svg viewBox="0 0 64 64" width="64" height="64">
|
||||||
|
<circle cx="32" cy="32" r="30" stroke="#C5D0E8" stroke-width="2" fill="none" stroke-dasharray="188" stroke-dashoffset="47"/>
|
||||||
|
<circle cx="32" cy="32" r="30" stroke="#1565C0" stroke-width="2.5" fill="none" stroke-dasharray="141" stroke-dashoffset="0" stroke-linecap="round"/>
|
||||||
|
<path d="M22 32c0-5.5 4.5-10 10-10s10 4.5 10 10c0 3-1.3 5.7-3.4 7.5" stroke="#0D47A1" stroke-width="1.5" fill="none"/>
|
||||||
|
<path d="M26 32c0-3.3 2.7-6 6-6s6 2.7 6 6c0 1.8-.8 3.4-2 4.5" stroke="#1565C0" stroke-width="1.5" fill="none"/>
|
||||||
|
<circle cx="32" cy="32" r="3" fill="#1565C0"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:13px;font-weight:500;color:#1A1A2E;margin-bottom:4px;">Huella dactilar</div>
|
||||||
|
<div style="font-size:11px;color:#6B7A99;">Coloque su dedo en el sensor</div>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:10px;color:#6B7A99;text-align:center;margin-bottom:8px;font-weight:500;text-transform:uppercase;letter-spacing:.5px;">o use</div>
|
||||||
|
<div style="display:flex;gap:8px;">
|
||||||
|
<div style="flex:1;background:#fff;border:0.5px solid #C5D0E8;border-radius:10px;padding:10px;display:flex;flex-direction:column;align-items:center;gap:5px;cursor:pointer;">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24"><circle cx="12" cy="8" r="4" stroke="#6B7A99" stroke-width="1.5" fill="none"/><path d="M4 20c0-4 3.6-7 8-7s8 3 8 7" stroke="#6B7A99" stroke-width="1.5" fill="none"/></svg>
|
||||||
|
<span style="font-size:10px;color:#6B7A99;">Face ID</span>
|
||||||
|
</div>
|
||||||
|
<div style="flex:1;background:#fff;border:0.5px solid #C5D0E8;border-radius:10px;padding:10px;display:flex;flex-direction:column;align-items:center;gap:5px;cursor:pointer;">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24"><rect x="5" y="2" width="14" height="20" rx="2" stroke="#6B7A99" stroke-width="1.5" fill="none"/><line x1="12" y1="18" x2="12.01" y2="18" stroke="#6B7A99" stroke-width="2" stroke-linecap="round"/></svg>
|
||||||
|
<span style="font-size:10px;color:#6B7A99;">PIN</span>
|
||||||
|
</div>
|
||||||
|
<div style="flex:1;background:#fff;border:0.5px solid #C5D0E8;border-radius:10px;padding:10px;display:flex;flex-direction:column;align-items:center;gap:5px;cursor:pointer;">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24"><path d="M22 16.92v3a2 2 0 01-2.18 2 19.79 19.79 0 01-8.63-3.07" stroke="#6B7A99" stroke-width="1.5" fill="none"/></svg>
|
||||||
|
<span style="font-size:10px;color:#6B7A99;">SMS OTP</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Screen 5: Completado -->
|
||||||
|
<div class="phone">
|
||||||
|
<div class="status-bar" style="background:#1B5E20;">
|
||||||
|
<span>9:41</span>
|
||||||
|
<div class="status-icons">
|
||||||
|
<svg width="16" height="10" viewBox="0 0 16 10"><rect x="0" y="1" width="13" height="8" rx="2" stroke="#fff" stroke-width="1" fill="none"/><rect x="13.5" y="3" width="2" height="4" rx="1" fill="#fff"/><rect x="1" y="2.5" width="9" height="5" rx="1" fill="#fff"/></svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="app-bar" style="background:#1B5E20;">
|
||||||
|
<div class="app-bar-title">Documento firmado</div>
|
||||||
|
</div>
|
||||||
|
<div class="body-area">
|
||||||
|
<div style="text-align:center;padding:14px 0 12px;">
|
||||||
|
<div style="width:56px;height:56px;background:#E8F5E9;border-radius:50%;margin:0 auto 12px;display:flex;align-items:center;justify-content:center;">
|
||||||
|
<svg width="28" height="28" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" stroke="#2E7D32" stroke-width="1.5" fill="#E8F5E9"/><polyline points="20 6 9 17 4 12" stroke="#2E7D32" stroke-width="2.5" fill="none"/></svg>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:14px;font-weight:500;color:#1A1A2E;">¡Firmado con éxito!</div>
|
||||||
|
<div style="font-size:11px;color:#6B7A99;margin-top:4px;">Contrato_servicios.pdf</div>
|
||||||
|
</div>
|
||||||
|
<div class="cert-card">
|
||||||
|
<div style="font-size:10px;font-weight:500;color:#6B7A99;margin-bottom:8px;text-transform:uppercase;letter-spacing:.5px;">Certificado digital</div>
|
||||||
|
<div class="cert-row"><span class="cert-key">Firmante</span><span class="cert-val">Carlos Mendieta</span></div>
|
||||||
|
<div class="cert-row"><span class="cert-key">Fecha y hora</span><span class="cert-val">06/04/2026 09:41</span></div>
|
||||||
|
<div class="cert-row"><span class="cert-key">Hash SHA-256</span><span class="cert-val" style="font-family:'Roboto Mono',monospace;">a4f2b9e1...</span></div>
|
||||||
|
<div class="cert-row" style="margin-bottom:0;"><span class="cert-key">Válido hasta</span><span class="cert-val" style="color:#2E7D32;">2027-04-06</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="action-row">
|
||||||
|
<div class="action-btn">
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4" stroke="#1565C0" stroke-width="2" fill="none"/><polyline points="7 10 12 15 17 10" stroke="#1565C0" stroke-width="2" fill="none"/><line x1="12" y1="15" x2="12" y2="3" stroke="#1565C0" stroke-width="2"/></svg>
|
||||||
|
<span>Guardar</span>
|
||||||
|
</div>
|
||||||
|
<div class="action-btn">
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24"><circle cx="18" cy="5" r="3" stroke="#1565C0" stroke-width="2" fill="none"/><circle cx="6" cy="12" r="3" stroke="#1565C0" stroke-width="2" fill="none"/><circle cx="18" cy="19" r="3" stroke="#1565C0" stroke-width="2" fill="none"/><line x1="8.59" y1="13.51" x2="15.42" y2="17.49" stroke="#1565C0" stroke-width="2"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49" stroke="#1565C0" stroke-width="2"/></svg>
|
||||||
|
<span>Compartir</span>
|
||||||
|
</div>
|
||||||
|
<div class="action-btn primary">
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24"><polyline points="9 14 4 9 9 4" stroke="#fff" stroke-width="2" fill="none"/><path d="M20 20v-7a4 4 0 00-4-4H4" stroke="#fff" stroke-width="2" fill="none"/></svg>
|
||||||
|
<span>Auditoría</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Flow bar -->
|
||||||
|
<div class="flow-bar">
|
||||||
|
<span class="flow-step">1. Inicio</span>
|
||||||
|
<svg width="16" height="12" viewBox="0 0 16 12"><polyline points="8 1 15 6 8 11" stroke="#C5D0E8" stroke-width="1.5" fill="none"/><line x1="1" y1="6" x2="15" y2="6" stroke="#C5D0E8" stroke-width="1.5"/></svg>
|
||||||
|
<span class="flow-step">2. Vista previa</span>
|
||||||
|
<svg width="16" height="12" viewBox="0 0 16 12"><polyline points="8 1 15 6 8 11" stroke="#C5D0E8" stroke-width="1.5" fill="none"/><line x1="1" y1="6" x2="15" y2="6" stroke="#C5D0E8" stroke-width="1.5"/></svg>
|
||||||
|
<span class="flow-step">3. Dibujar firma</span>
|
||||||
|
<svg width="16" height="12" viewBox="0 0 16 12"><polyline points="8 1 15 6 8 11" stroke="#C5D0E8" stroke-width="1.5" fill="none"/><line x1="1" y1="6" x2="15" y2="6" stroke="#C5D0E8" stroke-width="1.5"/></svg>
|
||||||
|
<span class="flow-step">4. Biometría</span>
|
||||||
|
<svg width="16" height="12" viewBox="0 0 16 12"><polyline points="8 1 15 6 8 11" stroke="#C5D0E8" stroke-width="1.5" fill="none"/><line x1="1" y1="6" x2="15" y2="6" stroke="#C5D0E8" stroke-width="1.5"/></svg>
|
||||||
|
<span class="flow-step success">5. Completado</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules
|
||||||
|
.expo
|
||||||
|
dist
|
||||||
|
android
|
||||||
|
ios
|
||||||
|
*.log
|
||||||
@@ -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 (
|
||||||
|
<SafeAreaProvider>
|
||||||
|
<AuthProvider>
|
||||||
|
<SyncProvider>
|
||||||
|
<StatusBar style="light" />
|
||||||
|
<AppNavigator />
|
||||||
|
</SyncProvider>
|
||||||
|
</AuthProvider>
|
||||||
|
</SafeAreaProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
module.exports = function (api) {
|
||||||
|
api.cache(true);
|
||||||
|
return {
|
||||||
|
presets: ['babel-preset-expo'],
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -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' },
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
@@ -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' },
|
||||||
|
});
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import client from './client';
|
||||||
|
|
||||||
|
export const listSignedDocuments = () => client.get('/signatures/mine');
|
||||||
|
export const signDocument = (payload) => client.post('/signatures/sign', payload);
|
||||||
@@ -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 (
|
||||||
|
<View style={styles.wrapper}>
|
||||||
|
{checks.map((check) => (
|
||||||
|
<View key={check.key} style={styles.row}>
|
||||||
|
<Text style={[styles.icon, check.valid ? styles.ok : styles.pending]}>
|
||||||
|
{check.valid ? '✓' : '•'}
|
||||||
|
</Text>
|
||||||
|
<Text style={[styles.label, check.valid ? styles.ok : styles.pending]}>
|
||||||
|
{check.label}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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 (
|
||||||
|
<Pressable
|
||||||
|
onPress={onPress}
|
||||||
|
disabled={disabled}
|
||||||
|
style={[
|
||||||
|
styles.button,
|
||||||
|
variant === 'secondary' && styles.secondary,
|
||||||
|
disabled && styles.disabled,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{disabled ? <ActivityIndicator color="#fff" /> : (
|
||||||
|
<Text style={[styles.label, variant === 'secondary' && styles.secondaryLabel]}>{title}</Text>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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 ? (
|
||||||
|
<ScrollView contentContainerStyle={[styles.scrollContent, contentContainerStyle]}>
|
||||||
|
{children}
|
||||||
|
</ScrollView>
|
||||||
|
) : (
|
||||||
|
<View style={[styles.scrollContent, contentContainerStyle]}>{children}</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
return <SafeAreaView style={styles.safe}>{content}</SafeAreaView>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
safe: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: colors.background,
|
||||||
|
},
|
||||||
|
scrollContent: {
|
||||||
|
padding: 16,
|
||||||
|
gap: 16,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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';
|
||||||
@@ -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 <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useAuth = () => useContext(AuthContext);
|
||||||
@@ -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 <SyncContext.Provider value={value}>{children}</SyncContext.Provider>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useSync = () => useContext(SyncContext);
|
||||||
@@ -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 (
|
||||||
|
<Tab.Navigator
|
||||||
|
screenOptions={{
|
||||||
|
headerShown: false,
|
||||||
|
tabBarActiveTintColor: colors.primary,
|
||||||
|
tabBarInactiveTintColor: colors.textMuted,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tab.Screen name="Inicio" component={DashboardScreen} />
|
||||||
|
<Tab.Screen name="Documentos" component={DocumentsScreen} />
|
||||||
|
<Tab.Screen name="Firmar" component={SignScreen} />
|
||||||
|
<Tab.Screen name="Certificados" component={CertificatesScreen} />
|
||||||
|
<Tab.Screen name="Perfil" component={ProfileScreen} />
|
||||||
|
</Tab.Navigator>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AppNavigator() {
|
||||||
|
const { loading, isAuthenticated } = useAuth();
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', backgroundColor: colors.background }}>
|
||||||
|
<ActivityIndicator size="large" color={colors.primary} />
|
||||||
|
<Text style={{ marginTop: 12, color: colors.textMuted }}>Cargando LinkedSign...</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NavigationContainer theme={navTheme}>
|
||||||
|
{isAuthenticated ? (
|
||||||
|
<AppTabs />
|
||||||
|
) : (
|
||||||
|
<AuthStack.Navigator screenOptions={{ headerShown: false }}>
|
||||||
|
<AuthStack.Screen name="Login" component={LoginScreen} />
|
||||||
|
<AuthStack.Screen name="Register" component={RegisterScreen} />
|
||||||
|
<AuthStack.Screen name="VerifyNotice" component={VerifyNoticeScreen} />
|
||||||
|
</AuthStack.Navigator>
|
||||||
|
)}
|
||||||
|
</NavigationContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<ScreenContainer>
|
||||||
|
<View style={styles.card}>
|
||||||
|
<Text style={styles.title}>Subir certificado</Text>
|
||||||
|
<TextInput style={styles.input} placeholder="Alias" value={alias} onChangeText={setAlias} />
|
||||||
|
<TextInput style={styles.input} placeholder="Contraseña del P12/PFX (opcional)" secureTextEntry value={password} onChangeText={setPassword} />
|
||||||
|
<PrimaryButton title="Seleccionar y subir" onPress={handleUpload} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<PrimaryButton title="Sincronizar certificados" onPress={handleSync} variant="secondary" />
|
||||||
|
|
||||||
|
<View style={styles.card}>
|
||||||
|
<Text style={styles.title}>Certificados sincronizados</Text>
|
||||||
|
<FlatList
|
||||||
|
data={certificates}
|
||||||
|
keyExtractor={(item) => `${item.id}`}
|
||||||
|
scrollEnabled={false}
|
||||||
|
ItemSeparatorComponent={() => <View style={{ height: 10 }} />}
|
||||||
|
renderItem={({ item }) => (
|
||||||
|
<View style={styles.item}>
|
||||||
|
<Text style={styles.itemTitle}>{item.alias}</Text>
|
||||||
|
<Text style={styles.itemMeta}>{item.subject_cn || 'Sin sujeto'}</Text>
|
||||||
|
<Text style={styles.itemMeta}>Válido hasta: {item.valid_to || 'N/D'}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
ListEmptyComponent={<Text style={styles.empty}>No hay certificados sincronizados.</Text>}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</ScreenContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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 (
|
||||||
|
<ScreenContainer>
|
||||||
|
<View style={styles.hero}>
|
||||||
|
<Text style={styles.greeting}>Hola, {user?.name || user?.email}</Text>
|
||||||
|
<Text style={styles.subtext}>LinkedSign móvil comparte autenticación con la app web y sincroniza datos bajo petición.</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.statsGrid}>
|
||||||
|
<View style={styles.statCard}>
|
||||||
|
<Text style={styles.statValue}>{documents.length}</Text>
|
||||||
|
<Text style={styles.statLabel}>Documentos</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.statCard}>
|
||||||
|
<Text style={styles.statValue}>{certificates.length}</Text>
|
||||||
|
<Text style={styles.statLabel}>Certificados</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.statCard}>
|
||||||
|
<Text style={styles.statValue}>{signedDocuments.length}</Text>
|
||||||
|
<Text style={styles.statLabel}>Firmados</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.card}>
|
||||||
|
<Text style={styles.cardTitle}>Sincronización local</Text>
|
||||||
|
<Text style={styles.cardText}>
|
||||||
|
La base local del teléfono se actualiza solo cuando tú lo pides. La sesión permanece guardada de forma segura.
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.syncMeta}>
|
||||||
|
Última sincronización: {lastSyncAt ? new Date(lastSyncAt).toLocaleString('es-EC') : 'Sin sincronizar'}
|
||||||
|
</Text>
|
||||||
|
<PrimaryButton title={syncing ? 'Sincronizando...' : 'Sincronizar ahora'} onPress={handleSync} disabled={syncing} />
|
||||||
|
</View>
|
||||||
|
</ScreenContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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 (
|
||||||
|
<View style={styles.section}>
|
||||||
|
<Text style={styles.sectionTitle}>{title}</Text>
|
||||||
|
{actionLabel ? <PrimaryButton title={actionLabel} onPress={onActionPress} /> : null}
|
||||||
|
{rows.length === 0 ? (
|
||||||
|
<Text style={styles.emptyText}>{emptyText}</Text>
|
||||||
|
) : (
|
||||||
|
<FlatList
|
||||||
|
data={rows}
|
||||||
|
keyExtractor={(item) => `${type}-${item.id}`}
|
||||||
|
scrollEnabled={false}
|
||||||
|
ItemSeparatorComponent={() => <View style={{ height: 10 }} />}
|
||||||
|
renderItem={({ item }) => (
|
||||||
|
<View style={styles.itemCard}>
|
||||||
|
<Text style={styles.itemTitle}>{item.title || item.signed_filename}</Text>
|
||||||
|
<Text style={styles.itemMeta}>
|
||||||
|
{item.status || item.document_title || 'Documento'}
|
||||||
|
</Text>
|
||||||
|
<PrimaryButton
|
||||||
|
title="Descargar / Compartir"
|
||||||
|
variant="secondary"
|
||||||
|
onPress={() => onDownload(item, token)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<ScreenContainer>
|
||||||
|
<PrimaryButton title="Sincronizar documentos" onPress={handleSync} variant="secondary" />
|
||||||
|
<SectionCard
|
||||||
|
title="Originales"
|
||||||
|
rows={documents}
|
||||||
|
emptyText="No hay documentos sincronizados en el teléfono."
|
||||||
|
actionLabel="Subir PDF"
|
||||||
|
onActionPress={handleUpload}
|
||||||
|
onDownload={handleDownloadOriginal}
|
||||||
|
token={tokens.accessToken}
|
||||||
|
type="original"
|
||||||
|
/>
|
||||||
|
<SectionCard
|
||||||
|
title="Firmados"
|
||||||
|
rows={signedDocuments}
|
||||||
|
emptyText="No hay documentos firmados sincronizados."
|
||||||
|
onDownload={handleDownloadSigned}
|
||||||
|
token={tokens.accessToken}
|
||||||
|
type="signed"
|
||||||
|
/>
|
||||||
|
</ScreenContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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 (
|
||||||
|
<ScreenContainer>
|
||||||
|
<View style={styles.card}>
|
||||||
|
<Text style={styles.title}>Perfil</Text>
|
||||||
|
<Text style={styles.item}><Text style={styles.label}>Correo:</Text> {user?.email}</Text>
|
||||||
|
<Text style={styles.item}><Text style={styles.label}>Rol:</Text> {user?.role}</Text>
|
||||||
|
<Text style={styles.item}><Text style={styles.label}>Última sincronización:</Text> {syncLabel}</Text>
|
||||||
|
<PrimaryButton title={syncing ? 'Sincronizando...' : 'Sincronizar ahora'} onPress={syncNow} disabled={syncing} variant="secondary" />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.card}>
|
||||||
|
<Text style={styles.title}>Solicitar cambio de contraseña</Text>
|
||||||
|
<TextInput style={styles.input} placeholder="Contraseña actual" secureTextEntry value={form.currentPassword} onChangeText={(value) => updateField('currentPassword', value)} />
|
||||||
|
<TextInput style={styles.input} placeholder="Nueva contraseña" secureTextEntry value={form.newPassword} onChangeText={(value) => updateField('newPassword', value)} />
|
||||||
|
<PasswordRules password={form.newPassword} />
|
||||||
|
<TextInput style={styles.input} placeholder="Confirmar nueva contraseña" secureTextEntry value={form.confirmPassword} onChangeText={(value) => updateField('confirmPassword', value)} />
|
||||||
|
<PrimaryButton title={saving ? 'Enviando...' : 'Enviar solicitud'} onPress={handlePasswordChange} disabled={saving} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<PrimaryButton title="Cerrar sesión" onPress={logout} variant="secondary" />
|
||||||
|
</ScreenContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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 (
|
||||||
|
<ScreenContainer>
|
||||||
|
<View style={styles.card}>
|
||||||
|
<Text style={styles.title}>Firma básica desde Android</Text>
|
||||||
|
<Text style={styles.subtitle}>Esta versión usa la configuración visual guardada y la posición por defecto del backend.</Text>
|
||||||
|
|
||||||
|
<Text style={styles.label}>Documento</Text>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
value={form.documentId}
|
||||||
|
onChangeText={(value) => setForm((current) => ({ ...current, documentId: value }))}
|
||||||
|
placeholder="ID de documento sincronizado"
|
||||||
|
/>
|
||||||
|
<Text style={styles.hint}>
|
||||||
|
Disponibles: {documents.map((item) => `${item.id}`).join(', ') || 'sin documentos'}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text style={styles.label}>Certificado</Text>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
value={form.certificateId}
|
||||||
|
onChangeText={(value) => setForm((current) => ({ ...current, certificateId: value }))}
|
||||||
|
placeholder="ID de certificado"
|
||||||
|
/>
|
||||||
|
<Text style={styles.hint}>
|
||||||
|
Disponibles: {activeCertificates.map((item) => `${item.id}`).join(', ') || 'sin certificados'}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
value={form.certPassword}
|
||||||
|
onChangeText={(value) => setForm((current) => ({ ...current, certPassword: value }))}
|
||||||
|
placeholder="Contraseña del certificado (si aplica)"
|
||||||
|
secureTextEntry
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
value={form.reason}
|
||||||
|
onChangeText={(value) => setForm((current) => ({ ...current, reason: value }))}
|
||||||
|
placeholder="Razón"
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
value={form.location}
|
||||||
|
onChangeText={(value) => setForm((current) => ({ ...current, location: value }))}
|
||||||
|
placeholder="Ubicación"
|
||||||
|
/>
|
||||||
|
<PrimaryButton title={loading ? 'Firmando...' : 'Firmar documento'} onPress={handleSign} disabled={loading} />
|
||||||
|
</View>
|
||||||
|
</ScreenContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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 (
|
||||||
|
<ScreenContainer>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Text style={styles.brand}>LinkedSign</Text>
|
||||||
|
<Text style={styles.subtitle}>Firma digital segura desde Android</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.card}>
|
||||||
|
<Text style={styles.title}>Iniciar sesión</Text>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
placeholder="Correo electrónico"
|
||||||
|
autoCapitalize="none"
|
||||||
|
keyboardType="email-address"
|
||||||
|
value={form.email}
|
||||||
|
onChangeText={(value) => updateField('email', value)}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
placeholder="Contraseña"
|
||||||
|
secureTextEntry
|
||||||
|
value={form.password}
|
||||||
|
onChangeText={(value) => updateField('password', value)}
|
||||||
|
/>
|
||||||
|
<PrimaryButton title={loading ? 'Ingresando...' : 'Iniciar sesión'} onPress={handleLogin} disabled={loading} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Pressable onPress={() => navigation.navigate('Register')}>
|
||||||
|
<Text style={styles.link}>¿No tienes cuenta? Regístrate</Text>
|
||||||
|
</Pressable>
|
||||||
|
</ScreenContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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 (
|
||||||
|
<ScreenContainer>
|
||||||
|
<View style={styles.card}>
|
||||||
|
<Text style={styles.title}>Crear cuenta</Text>
|
||||||
|
<Text style={styles.subtitle}>Regístrate con correo y contraseña segura.</Text>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
placeholder="Correo electrónico"
|
||||||
|
autoCapitalize="none"
|
||||||
|
keyboardType="email-address"
|
||||||
|
value={form.email}
|
||||||
|
onChangeText={(value) => updateField('email', value)}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
placeholder="Contraseña"
|
||||||
|
secureTextEntry
|
||||||
|
value={form.password}
|
||||||
|
onChangeText={(value) => updateField('password', value)}
|
||||||
|
/>
|
||||||
|
<PasswordRules password={form.password} />
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
placeholder="Confirmar contraseña"
|
||||||
|
secureTextEntry
|
||||||
|
value={form.confirmPassword}
|
||||||
|
onChangeText={(value) => updateField('confirmPassword', value)}
|
||||||
|
/>
|
||||||
|
<PrimaryButton title={loading ? 'Creando...' : 'Crear cuenta'} onPress={handleRegister} disabled={loading} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Pressable onPress={() => navigation.goBack()}>
|
||||||
|
<Text style={styles.link}>Volver al inicio de sesión</Text>
|
||||||
|
</Pressable>
|
||||||
|
</ScreenContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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 (
|
||||||
|
<ScreenContainer>
|
||||||
|
<View style={styles.card}>
|
||||||
|
<Text style={styles.title}>Confirma tu cuenta</Text>
|
||||||
|
<Text style={styles.text}>
|
||||||
|
Enviamos un enlace de validación a {email || 'tu correo'}. Debes confirmarlo antes de iniciar sesión.
|
||||||
|
</Text>
|
||||||
|
<PrimaryButton title="Volver al login" onPress={() => navigation.navigate('Login')} />
|
||||||
|
</View>
|
||||||
|
</ScreenContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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',
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
@@ -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`
|
||||||
Executable
+16
@@ -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"
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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.
|
||||||
Generated
+1878
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+12
@@ -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
|
||||||
|
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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 };
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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 };
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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;
|
||||||
@@ -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 };
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
const getStatus = async () => {
|
||||||
|
return {
|
||||||
|
status: 'ok',
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getStatus,
|
||||||
|
};
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const healthController = require('./health.controller');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.get('/', healthController.getHealth);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user