Initial LinkedSign platform

This commit is contained in:
2026-04-06 20:33:59 -05:00
commit 1074c7c9d2
159 changed files with 19072 additions and 0 deletions
+81
View File
@@ -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
+172
View File
@@ -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.
+102
View File
@@ -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).
+78
View File
@@ -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'
}
+1
View File
@@ -0,0 +1 @@
# LinkedSign Android native app
+34
View File
@@ -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>
+25
View File
@@ -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
}
+4
View File
@@ -0,0 +1,4 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
android.nonTransitiveRClass=true
kotlin.code.style=official
Binary file not shown.
+5
View File
@@ -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
Vendored Executable
+252
View File
@@ -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" "$@"
+2
View File
@@ -0,0 +1,2 @@
rootProject.name = "LinkedSign"
include ':app'
+457
View File
@@ -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>
+6
View File
@@ -0,0 +1,6 @@
node_modules
.expo
dist
android
ios
*.log
+18
View File
@@ -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>
);
}
+101
View File
@@ -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.
+22
View File
@@ -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"
}
}
}
+6
View File
@@ -0,0 +1,6 @@
module.exports = function (api) {
api.cache(true);
return {
presets: ['babel-preset-expo'],
};
};
+29
View File
@@ -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"
}
}
+7
View File
@@ -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);
+7
View File
@@ -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' },
});
+78
View File
@@ -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;
+7
View File
@@ -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' },
});
+4
View File
@@ -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,
},
});
+8
View File
@@ -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';
+86
View File
@@ -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);
+53
View File
@@ -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',
},
});
+156
View File
@@ -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,
},
});
+13
View File
@@ -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',
};
+17
View File
@@ -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;
};
+10
View File
@@ -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);
+48
View File
@@ -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`
+16
View File
@@ -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"
+20
View File
@@ -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
+145
View File
@@ -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
+210
View File
@@ -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.
+1878
View File
File diff suppressed because it is too large Load Diff
+32
View File
@@ -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"
}
}
+12
View File
@@ -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
+29
View File
@@ -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;
+17
View File
@@ -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;
+45
View File
@@ -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;
+57
View File
@@ -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;
+19
View File
@@ -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;
+29
View File
@@ -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;
+28
View File
@@ -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;
+7
View File
@@ -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;
+11
View File
@@ -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 };
+115
View File
@@ -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,
};
+17
View File
@@ -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;
+286
View File
@@ -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