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