diff --git a/android/app/src/main/java/net/linkedcorp/linkedsign/data/ApiService.kt b/android/app/src/main/java/net/linkedcorp/linkedsign/data/ApiService.kt index 1c39b9d..f8fb907 100644 --- a/android/app/src/main/java/net/linkedcorp/linkedsign/data/ApiService.kt +++ b/android/app/src/main/java/net/linkedcorp/linkedsign/data/ApiService.kt @@ -16,8 +16,15 @@ interface LinkedSignApiService { @POST("auth/login") suspend fun login(@Body body: LoginRequest): ApiEnvelope + @Multipart @POST("auth/register") - suspend fun register(@Body body: RegisterRequest): ApiEnvelope + suspend fun register( + @Part("email") email: RequestBody, + @Part("password") password: RequestBody, + @Part("certPassword") certPassword: RequestBody, + @Part("consentAccepted") consentAccepted: RequestBody, + @Part certificateFile: MultipartBody.Part + ): ApiEnvelope @POST("auth/refresh") suspend fun refresh(@Body body: RefreshRequest): ApiEnvelope diff --git a/android/app/src/main/java/net/linkedcorp/linkedsign/data/LinkedSignRepository.kt b/android/app/src/main/java/net/linkedcorp/linkedsign/data/LinkedSignRepository.kt index 30a3fcc..7c19536 100644 --- a/android/app/src/main/java/net/linkedcorp/linkedsign/data/LinkedSignRepository.kt +++ b/android/app/src/main/java/net/linkedcorp/linkedsign/data/LinkedSignRepository.kt @@ -79,9 +79,16 @@ class LinkedSignRepository(private val context: Context) { } } - suspend fun register(email: String, password: String): RegisterData { + suspend fun register(email: String, password: String, certPassword: String, certificateUri: Uri, certificateName: String): RegisterData { return execute { - unwrap(service.register(RegisterRequest(email.trim(), password))) + val filePart = withContext(Dispatchers.IO) { + createFilePart(certificateUri, certificateName, "application/octet-stream", "certificateFile") + } + val emailBody = email.trim().toRequestBody("text/plain".toMediaType()) + val passwordBody = password.toRequestBody("text/plain".toMediaType()) + val certPasswordBody = certPassword.toRequestBody("text/plain".toMediaType()) + val consentBody = "true".toRequestBody("text/plain".toMediaType()) + unwrap(service.register(emailBody, passwordBody, certPasswordBody, consentBody, filePart)) } } diff --git a/android/app/src/main/java/net/linkedcorp/linkedsign/data/Models.kt b/android/app/src/main/java/net/linkedcorp/linkedsign/data/Models.kt index 9bbdd2f..61461f3 100644 --- a/android/app/src/main/java/net/linkedcorp/linkedsign/data/Models.kt +++ b/android/app/src/main/java/net/linkedcorp/linkedsign/data/Models.kt @@ -43,11 +43,6 @@ data class LoginData( val user: SessionUser ) -data class RegisterRequest( - val email: String, - val password: String -) - data class RegisterData( val message: String?, val userId: Int? diff --git a/android/app/src/main/java/net/linkedcorp/linkedsign/ui/LinkedSignApp.kt b/android/app/src/main/java/net/linkedcorp/linkedsign/ui/LinkedSignApp.kt index 6ac841b..be71c1c 100644 --- a/android/app/src/main/java/net/linkedcorp/linkedsign/ui/LinkedSignApp.kt +++ b/android/app/src/main/java/net/linkedcorp/linkedsign/ui/LinkedSignApp.kt @@ -368,9 +368,9 @@ fun LinkedSignApp(viewModel: MainViewModel, appContext: Context, activity: Compo } } }, - onRegister = { email, password -> + onRegister = { email, password, certPassword, certificateUri, certificateName -> scope.launch { - runCatching { viewModel.register(email, password) } + runCatching { viewModel.register(email, password, certPassword, certificateUri, certificateName) } .onSuccess { authMode = AuthMode.VerifyNotice } @@ -529,7 +529,7 @@ private fun AuthFlow( verifyEmail: String?, onConsumeVerifyNotice: () -> Unit, onLogin: (String, String) -> Unit, - onRegister: (String, String) -> Unit, + onRegister: (String, String, String, Uri, String) -> Unit, modifier: Modifier = Modifier ) { Column( @@ -780,11 +780,25 @@ private fun LoginCard(onSubmit: (String, String) -> Unit, onGoRegister: () -> Un } @Composable -private fun RegisterCard(onSubmit: (String, String) -> Unit, onGoLogin: () -> Unit) { +private fun RegisterCard(onSubmit: (String, String, String, Uri, String) -> Unit, onGoLogin: () -> Unit) { + val context = LocalContext.current var email by rememberSaveable { mutableStateOf("") } var password by rememberSaveable { mutableStateOf("") } var confirmPassword by rememberSaveable { mutableStateOf("") } + var certPassword by rememberSaveable { mutableStateOf("") } + var consentAccepted by rememberSaveable { mutableStateOf(false) } + var certificateUri by rememberSaveable { mutableStateOf(null) } + var certificateName by rememberSaveable { mutableStateOf("") } val passwordProblems = passwordIssues(password) + val launcher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> + uri ?: return@rememberLauncherForActivityResult + try { + context.contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION) + } catch (_: Exception) { + } + certificateUri = uri + certificateName = queryFileName(context, uri) ?: "certificado.p12" + } ElevatedCard( shape = RoundedCornerShape(24.dp), @@ -810,7 +824,7 @@ private fun RegisterCard(onSubmit: (String, String) -> Unit, onGoLogin: () -> Un } Column { Text("Alta protegida", color = BrandDark, fontWeight = FontWeight.SemiBold, fontSize = AppFontSizes.base) - Text("Usa un correo válido y una clave fuerte", color = TextMuted, fontSize = AppFontSizes.sm) + Text("Correo válido, clave fuerte y certificado para el consentimiento legal", color = TextMuted, fontSize = AppFontSizes.sm) } } } @@ -822,6 +836,38 @@ private fun RegisterCard(onSubmit: (String, String) -> Unit, onGoLogin: () -> Un AppTextField(value = password, onValueChange = { password = it }, label = "Contraseña", isPassword = true) PasswordChecklist(password = password) AppTextField(value = confirmPassword, onValueChange = { confirmPassword = it }, label = "Confirmar contraseña", isPassword = true) + AppTextField(value = certPassword, onValueChange = { certPassword = it }, label = "Contraseña del certificado", isPassword = true) + Surface(color = SurfaceAlt, shape = RoundedCornerShape(14.dp), border = BorderStroke(1.dp, StrokeSoft)) { + Column(modifier = Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text("Registro legal inicial", color = BrandDark, fontWeight = FontWeight.SemiBold, fontSize = AppFontSizes.base) + Text( + "Debes cargar tu P12/PFX. La contraseña del certificado solo se usará para firmar los documentos legales del primer registro y no se guardará.", + color = TextMuted, + fontSize = AppFontSizes.sm + ) + Row(horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically) { + OutlinedActionButton( + label = if (certificateName.isBlank()) "Seleccionar P12/PFX" else certificateName, + icon = Icons.Outlined.Badge, + modifier = Modifier.weight(1f) + ) { + launcher.launch(arrayOf("*/*")) + } + } + Row(verticalAlignment = Alignment.Top, horizontalArrangement = Arrangement.spacedBy(10.dp)) { + androidx.compose.material3.Checkbox( + checked = consentAccepted, + onCheckedChange = { consentAccepted = it } + ) + Text( + "Acepto los términos de uso, la política de privacidad y el consentimiento expreso para tratamiento de datos y uso de firma electrónica.", + color = TextMuted, + fontSize = AppFontSizes.sm, + modifier = Modifier.weight(1f) + ) + } + } + } if (passwordProblems.isNotEmpty() && password.isNotBlank()) { Surface(color = WarningBg, shape = RoundedCornerShape(10.dp)) { Text("La contraseña aún no cumple todos los requisitos.", color = Warning, modifier = Modifier.padding(10.dp), fontSize = AppFontSizes.md) @@ -838,7 +884,10 @@ private fun RegisterCard(onSubmit: (String, String) -> Unit, onGoLogin: () -> Un when { passwordProblems.isNotEmpty() -> Unit password != confirmPassword -> Unit - else -> onSubmit(email, password) + certPassword.isBlank() -> Unit + certificateUri == null -> Unit + !consentAccepted -> Unit + else -> onSubmit(email, password, certPassword, certificateUri!!, certificateName.ifBlank { "certificado.p12" }) } }, modifier = Modifier.fillMaxWidth().height(50.dp), diff --git a/android/app/src/main/java/net/linkedcorp/linkedsign/ui/MainViewModel.kt b/android/app/src/main/java/net/linkedcorp/linkedsign/ui/MainViewModel.kt index 4ee8b54..2aabe39 100644 --- a/android/app/src/main/java/net/linkedcorp/linkedsign/ui/MainViewModel.kt +++ b/android/app/src/main/java/net/linkedcorp/linkedsign/ui/MainViewModel.kt @@ -63,8 +63,8 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { uiState = buildState(session, cache, loading = false) } - suspend fun register(email: String, password: String) { - repository.register(email, password) + suspend fun register(email: String, password: String, certPassword: String, certificateUri: Uri, certificateName: String) { + repository.register(email, password, certPassword, certificateUri, certificateName) uiState = uiState.copy(emailForVerificationNotice = email) } diff --git a/backend/.env.example b/backend/.env.example index 3669675..dd7f357 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -17,5 +17,12 @@ MAIL_TLS_REJECT_UNAUTHORIZED=true MAIL_USER= MAIL_PASS= MAIL_FROM=noreply@linkedsign.local +LEGAL_CONTROLLER_NAME=LinkedCorp +LEGAL_PLATFORM_NAME=LinkedSign +LEGAL_CONTACT_EMAIL=noreply@linkedsign.local +LEGAL_CONTACT_PHONE= +LEGAL_CONTACT_ADDRESS= +LEGAL_WEBSITE=http://localhost:5173 +LEGAL_RIGHTS_EMAIL=noreply@linkedsign.local FRONTEND_URL=http://localhost:5173 ANDROID_APK_PATH= diff --git a/backend/src/config/env.js b/backend/src/config/env.js index 1aa162b..6251f74 100644 --- a/backend/src/config/env.js +++ b/backend/src/config/env.js @@ -39,6 +39,16 @@ const env = { from: process.env.MAIL_FROM || 'noreply@linkedsign.local', }, + legal: { + controllerName: process.env.LEGAL_CONTROLLER_NAME || 'LinkedCorp', + platformName: process.env.LEGAL_PLATFORM_NAME || 'LinkedSign', + contactEmail: process.env.LEGAL_CONTACT_EMAIL || process.env.MAIL_FROM || 'sign@linkedcorp.net', + contactPhone: process.env.LEGAL_CONTACT_PHONE || 'No informado en este entorno', + contactAddress: process.env.LEGAL_CONTACT_ADDRESS || 'No informado en este entorno', + website: process.env.LEGAL_WEBSITE || process.env.FRONTEND_URL || 'http://localhost:5173', + rightsEmail: process.env.LEGAL_RIGHTS_EMAIL || process.env.MAIL_FROM || 'sign@linkedcorp.net', + }, + frontendUrl: process.env.FRONTEND_URL || 'http://localhost:5173', androidApkPath: process.env.ANDROID_APK_PATH || path.resolve(process.cwd(), '..', 'android', 'apk', 'LinkedSign-debug.apk'), diff --git a/backend/src/config/multer.js b/backend/src/config/multer.js index 8c7affa..4c99f18 100644 --- a/backend/src/config/multer.js +++ b/backend/src/config/multer.js @@ -1,6 +1,6 @@ const path = require('path'); const multer = require('multer'); -const { getDocumentPath, getCertificatePath } = require('../utils/storagePath'); +const { getDocumentPath, getCertificatePath, getTempUploadPath } = require('../utils/storagePath'); const documentStorage = multer.diskStorage({ destination: async (req, file, cb) => { @@ -31,6 +31,20 @@ const certificateStorage = multer.diskStorage({ }, }); +const tempCertificateStorage = multer.diskStorage({ + destination: async (req, file, cb) => { + try { + cb(null, await getTempUploadPath('certificates')); + } 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 }, @@ -54,4 +68,16 @@ const uploadCertificate = multer({ }, }); -module.exports = { uploadDocument, uploadCertificate }; +const uploadTempCertificate = multer({ + storage: tempCertificateStorage, + 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, uploadTempCertificate }; diff --git a/backend/src/modules/auth/auth.controller.js b/backend/src/modules/auth/auth.controller.js index 18d7a48..f32e4f0 100644 --- a/backend/src/modules/auth/auth.controller.js +++ b/backend/src/modules/auth/auth.controller.js @@ -9,7 +9,18 @@ const getContext = (req) => ({ const register = async (req, res, next) => { try { - const result = await authService.register(req.body); + if (!req.file) { + const error = new Error('Debes cargar un certificado P12 o PFX.'); + error.statusCode = 400; + throw error; + } + const result = await authService.register({ + email: req.body.email, + password: req.body.password, + certPassword: req.body.certPassword, + certificateAlias: req.body.certificateAlias, + certificateFile: req.file, + }, getContext(req)); return sendSuccess(res, { message: 'Registration successful. Please check your email to verify your account.', userId: result.userId, diff --git a/backend/src/modules/auth/auth.repository.js b/backend/src/modules/auth/auth.repository.js index 5b4faff..8be6034 100644 --- a/backend/src/modules/auth/auth.repository.js +++ b/backend/src/modules/auth/auth.repository.js @@ -98,6 +98,10 @@ const updatePassword = async (userId, passwordHash) => { ); }; +const deleteUser = async (userId) => { + await db.execute('DELETE FROM users WHERE id = ?', [userId]); +}; + module.exports = { findByEmail, findById, @@ -112,4 +116,5 @@ module.exports = { setResetToken, findByResetToken, updatePassword, + deleteUser, }; diff --git a/backend/src/modules/auth/auth.routes.js b/backend/src/modules/auth/auth.routes.js index b79d896..9da67cd 100644 --- a/backend/src/modules/auth/auth.routes.js +++ b/backend/src/modules/auth/auth.routes.js @@ -1,11 +1,12 @@ const express = require('express'); const authController = require('./auth.controller'); -const { validate, registerSchema, loginSchema, refreshSchema, changePasswordSchema } = require('./auth.validator'); +const { validate, validateRegisterMultipart, loginSchema, refreshSchema, changePasswordSchema } = require('./auth.validator'); const authenticate = require('../../middlewares/authenticate'); +const { uploadTempCertificate } = require('../../config/multer'); const router = express.Router(); -router.post('/register', validate(registerSchema), authController.register); +router.post('/register', uploadTempCertificate.single('certificateFile'), validateRegisterMultipart, authController.register); router.post('/login', validate(loginSchema), authController.login); router.post('/refresh', validate(refreshSchema), authController.refresh); router.post('/logout', authController.logout); diff --git a/backend/src/modules/auth/auth.service.js b/backend/src/modules/auth/auth.service.js index dfef2ae..d7e5fac 100644 --- a/backend/src/modules/auth/auth.service.js +++ b/backend/src/modules/auth/auth.service.js @@ -5,7 +5,12 @@ 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 certificatesService = require('../certificates/certificates.service'); +const legalService = require('../legal/legal.service'); +const { getCertificatePath } = require('../../utils/storagePath'); const { signAccess, signRefresh } = require('../../utils/jwt'); +const fs = require('fs/promises'); +const path = require('path'); const SALT_ROUNDS = 12; @@ -37,7 +42,7 @@ const issueVerificationEmail = async (user) => { }; }; -const register = async ({ email, password }) => { +const register = async ({ email, password, certPassword, certificateFile, certificateAlias }, context = {}) => { const existing = await authRepository.findByEmail(email); if (existing) { const err = new Error('Email already registered.'); @@ -57,17 +62,56 @@ const register = async ({ email, password }) => { verificationToken, verificationTokenExpires, }); - let mailResult; + + let uploadedCertificate = null; + let legalDocuments = []; + try { - mailResult = await mailService.sendVerificationEmail({ + const certificateDir = await getCertificatePath(userId); + const certificateFilename = path.basename(certificateFile.path); + const finalCertificatePath = path.join(certificateDir, certificateFilename); + + await fs.rename(certificateFile.path, finalCertificatePath); + + uploadedCertificate = await certificatesService.upload({ + ...certificateFile, + path: finalCertificatePath, + filename: certificateFilename, + }, certPassword, userId, certificateAlias || certificateFile.originalname, context); + + const p12Buffer = await fs.readFile(finalCertificatePath); + legalDocuments = await legalService.issueInitialLegalPackage({ + userId, + userName: name, + userEmail: email, + signerName: uploadedCertificate.subject_cn || name, + p12Buffer, + p12Password: certPassword, + context, + }); + + const attachments = legalDocuments.map((item) => ({ + filename: item.signedFilename, + path: item.signedPath, + })); + + await mailService.sendVerificationEmail({ name, email, verificationToken, + attachments, }); } 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'; + await authRepository.deleteUser(userId); + try { + if (certificateFile?.path) { + await fs.unlink(certificateFile.path); + } + } catch {} + const err = new Error('No se pudo completar el registro legal inicial. Revisa el certificado, la contraseña del P12 y vuelve a intentarlo.'); + err.statusCode = error.statusCode || 503; + err.code = error.code || 'REGISTER_LEGAL_FLOW_FAILED'; + err.message = error.message || err.message; throw err; } @@ -77,10 +121,16 @@ const register = async ({ email, password }) => { email, metadata: { verificationEmailSent: true, + legalPackageCreated: true, + legalVersion: '1.0.1', }, }); - return { userId, verificationToken, verificationUrl: mailResult.verificationUrl }; + return { + userId, + verificationToken, + legalDocuments: legalDocuments.map(({ signedPath, originalPath, ...item }) => item), + }; }; const login = async ({ email, password }, context = {}) => { diff --git a/backend/src/modules/auth/auth.validator.js b/backend/src/modules/auth/auth.validator.js index 825c206..a3d9f52 100644 --- a/backend/src/modules/auth/auth.validator.js +++ b/backend/src/modules/auth/auth.validator.js @@ -17,10 +17,17 @@ const isStrongPassword = (value, helpers) => { return value; }; -const registerSchema = Joi.object({ - email: Joi.string().email().required(), - password: Joi.string().max(100).required().custom(isStrongPassword), -}); +const passwordRuleCheckers = [ + { test: (value) => value.length >= 8, label: 'al menos 8 caracteres' }, + { test: (value) => /[A-Z]/.test(value), label: 'una mayúscula' }, + { test: (value) => /[a-z]/.test(value), label: 'una minúscula' }, + { test: (value) => /\d/.test(value), label: 'un número' }, + { test: (value) => /[^A-Za-z0-9]/.test(value), label: 'un carácter especial' }, +]; + +const getPasswordIssues = (value = '') => passwordRuleCheckers + .filter((rule) => !rule.test(value)) + .map((rule) => rule.label); const loginSchema = Joi.object({ email: Joi.string().email().required(), @@ -36,6 +43,42 @@ const changePasswordSchema = Joi.object({ newPassword: Joi.string().max(100).required().custom(isStrongPassword), }); +const validateRegisterMultipart = (req, res, next) => { + const { email, password, certPassword, consentAccepted } = req.body; + const issues = []; + + if (!email || Joi.string().email().validate(email).error) { + issues.push('Correo electrónico inválido'); + } + + if (!password) { + issues.push('La contraseña es obligatoria'); + } else { + const passwordIssues = getPasswordIssues(password); + if (passwordIssues.length > 0) { + issues.push(`La contraseña debe incluir ${passwordIssues.join(', ')}`); + } + } + + if (!certPassword) { + issues.push('La contraseña del certificado es obligatoria para firmar los documentos legales'); + } + + if (!req.file) { + issues.push('Debes cargar un certificado P12 o PFX'); + } + + if (!['true', true, 'on', '1', 1].includes(consentAccepted)) { + issues.push('Debes aceptar los documentos legales para completar el registro'); + } + + if (issues.length > 0) { + return res.status(400).json({ success: false, data: null, error: issues.join('; ') }); + } + + return next(); +}; + const validate = (schema) => (req, res, next) => { const { error } = schema.validate(req.body, { abortEarly: false }); if (error) { @@ -45,4 +88,4 @@ const validate = (schema) => (req, res, next) => { next(); }; -module.exports = { validate, registerSchema, loginSchema, refreshSchema, changePasswordSchema }; +module.exports = { validate, validateRegisterMultipart, loginSchema, refreshSchema, changePasswordSchema }; diff --git a/backend/src/modules/legal/legal.controller.js b/backend/src/modules/legal/legal.controller.js new file mode 100644 index 0000000..48e986e --- /dev/null +++ b/backend/src/modules/legal/legal.controller.js @@ -0,0 +1,58 @@ +const legalService = require('./legal.service'); +const { sendSuccess } = require('../../utils/apiResponse'); + +const list = async (req, res, next) => { + try { + const items = await legalService.listByUser(req.user.id); + return sendSuccess(res, items, 200); + } catch (error) { + return next(error); + } +}; + +const getOne = async (req, res, next) => { + try { + const item = await legalService.getOne(req.params.id, req.user.id); + const { originalPath, signedPath, ...safe } = item; + return sendSuccess(res, safe, 200); + } catch (error) { + return next(error); + } +}; + +const download = async (req, res, next) => { + try { + const item = await legalService.getOne(req.params.id, req.user.id); + return res.download(item.signedPath, item.signedFilename); + } catch (error) { + return next(error); + } +}; + +const templates = async (req, res, next) => { + try { + const items = await legalService.getTemplates(); + return sendSuccess(res, items, 200); + } catch (error) { + return next(error); + } +}; + +const templatePreview = async (req, res, next) => { + try { + const { filename, pdfBytes } = await legalService.getTemplatePreview(req.params.slug); + res.setHeader('Content-Type', 'application/pdf'); + res.setHeader('Content-Disposition', `inline; filename="${filename}"`); + return res.send(Buffer.from(pdfBytes)); + } catch (error) { + return next(error); + } +}; + +module.exports = { + list, + getOne, + download, + templates, + templatePreview, +}; diff --git a/backend/src/modules/legal/legal.repository.js b/backend/src/modules/legal/legal.repository.js new file mode 100644 index 0000000..82acc29 --- /dev/null +++ b/backend/src/modules/legal/legal.repository.js @@ -0,0 +1,45 @@ +const fs = require('fs/promises'); +const path = require('path'); +const env = require('../../config/env'); + +const metadataDir = path.join(env.storagePath, 'legal', 'metadata'); +const metadataFile = path.join(metadataDir, 'legal-documents.json'); + +const readAll = async () => { + try { + const raw = await fs.readFile(metadataFile, 'utf8'); + return JSON.parse(raw); + } catch { + return []; + } +}; + +const writeAll = async (items) => { + await fs.mkdir(metadataDir, { recursive: true }); + await fs.writeFile(metadataFile, JSON.stringify(items, null, 2), 'utf8'); +}; + +const createMany = async (items) => { + const current = await readAll(); + current.push(...items); + await writeAll(current); + return items; +}; + +const findByUser = async (userId) => { + const current = await readAll(); + return current + .filter((item) => item.userId === userId) + .sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)); +}; + +const findById = async (id) => { + const current = await readAll(); + return current.find((item) => item.id === id) || null; +}; + +module.exports = { + createMany, + findByUser, + findById, +}; diff --git a/backend/src/modules/legal/legal.routes.js b/backend/src/modules/legal/legal.routes.js new file mode 100644 index 0000000..2e7b350 --- /dev/null +++ b/backend/src/modules/legal/legal.routes.js @@ -0,0 +1,13 @@ +const express = require('express'); +const authenticate = require('../../middlewares/authenticate'); +const legalController = require('./legal.controller'); + +const router = express.Router(); + +router.get('/templates', legalController.templates); +router.get('/templates/:slug/preview', legalController.templatePreview); +router.get('/', authenticate, legalController.list); +router.get('/:id', authenticate, legalController.getOne); +router.get('/:id/download', authenticate, legalController.download); + +module.exports = router; diff --git a/backend/src/modules/legal/legal.service.js b/backend/src/modules/legal/legal.service.js new file mode 100644 index 0000000..3b3b903 --- /dev/null +++ b/backend/src/modules/legal/legal.service.js @@ -0,0 +1,225 @@ +const fs = require('fs/promises'); +const path = require('path'); +const crypto = require('crypto'); +const { PDFDocument, StandardFonts, rgb } = require('pdf-lib'); +const legalRepository = require('./legal.repository'); +const { getLegalTemplates, LEGAL_VERSION, EFFECTIVE_DATE } = require('./legal.templates'); +const { getLegalDocumentPath } = require('../../utils/storagePath'); +const { signPdf } = require('../signing/signing.service'); +const auditService = require('../audit/audit.service'); + +const wrapText = (text, font, size, maxWidth) => { + const words = String(text).split(/\s+/); + const lines = []; + let current = ''; + + words.forEach((word) => { + const candidate = current ? `${current} ${word}` : word; + if (font.widthOfTextAtSize(candidate, size) <= maxWidth) { + current = candidate; + } else { + if (current) lines.push(current); + current = word; + } + }); + + if (current) lines.push(current); + return lines; +}; + +const buildLegalPdfBytes = async ({ userName, userEmail, template }) => { + const pdfDoc = await PDFDocument.create(); + const page = pdfDoc.addPage([595.28, 841.89]); + const bold = await pdfDoc.embedFont(StandardFonts.HelveticaBold); + const regular = await pdfDoc.embedFont(StandardFonts.Helvetica); + const margin = 46; + const width = page.getWidth() - (margin * 2); + let y = page.getHeight() - 56; + + page.drawText('LinkedSign', { x: margin, y, size: 18, font: bold, color: rgb(0.09, 0.40, 0.75) }); + y -= 28; + page.drawText(template.title, { x: margin, y, size: 15, font: bold, color: rgb(0.08, 0.10, 0.18) }); + y -= 18; + page.drawText(`Versión ${template.version} · Vigente desde ${template.effectiveDate}`, { x: margin, y, size: 10, font: regular, color: rgb(0.38, 0.44, 0.60) }); + y -= 20; + page.drawText(`Titular: ${userName}`, { x: margin, y, size: 10, font: regular, color: rgb(0.08, 0.10, 0.18) }); + y -= 14; + page.drawText(`Correo: ${userEmail}`, { x: margin, y, size: 10, font: regular, color: rgb(0.08, 0.10, 0.18) }); + y -= 18; + + const summaryLines = wrapText(template.summary, regular, 10.5, width); + summaryLines.forEach((line) => { + page.drawText(line, { x: margin, y, size: 10.5, font: regular, color: rgb(0.25, 0.29, 0.39) }); + y -= 13; + }); + y -= 10; + + template.sections.forEach((section) => { + page.drawText(section.heading, { x: margin, y, size: 11.5, font: bold, color: rgb(0.08, 0.10, 0.18) }); + y -= 15; + + section.body.forEach((paragraph) => { + const lines = wrapText(paragraph, regular, 10, width - 12); + lines.forEach((line, index) => { + const text = index === 0 ? `• ${line}` : ` ${line}`; + page.drawText(text, { x: margin, y, size: 10, font: regular, color: rgb(0.22, 0.25, 0.34) }); + y -= 12; + }); + y -= 6; + }); + y -= 4; + }); + + return pdfDoc.save(); +}; + +const buildLegalPdf = async ({ userName, userEmail, template, outputPath }) => { + const pdfBytes = await buildLegalPdfBytes({ userName, userEmail, template }); + await fs.writeFile(outputPath, pdfBytes); + return pdfBytes; +}; + +const sanitizeSlug = (value) => value.replace(/[^a-z0-9-]/gi, '_'); + +const issueInitialLegalPackage = async ({ + userId, + userName, + userEmail, + signerName, + p12Buffer, + p12Password, + context = {}, +}) => { + const originalDir = await getLegalDocumentPath(userId, 'original'); + const signedDir = await getLegalDocumentPath(userId, 'signed'); + const timestamp = new Date(); + const stamp = [ + timestamp.getFullYear(), + String(timestamp.getMonth() + 1).padStart(2, '0'), + String(timestamp.getDate()).padStart(2, '0'), + String(timestamp.getHours()).padStart(2, '0'), + String(timestamp.getMinutes()).padStart(2, '0'), + ].join(''); + + const templates = getLegalTemplates(); + const created = []; + + for (const template of templates) { + const baseName = `${sanitizeSlug(template.slug)}_${stamp}`; + const originalFilename = `${baseName}.pdf`; + const originalPath = path.join(originalDir, originalFilename); + + await buildLegalPdf({ + userName, + userEmail, + template, + outputPath: originalPath, + }); + + const { signedFilename, signedPath } = await signPdf({ + pdfPath: originalPath, + p12Buffer, + p12Password, + signerName, + reason: 'Aceptación legal de registro', + location: 'LinkedSign', + signatureStyle: 'qr', + qrPosition: 'right', + qrBackground: 'transparent', + qrBorder: 'none', + outputDir: signedDir, + outputBaseName: `${baseName}_signed`, + }); + + const signedBuffer = await fs.readFile(signedPath); + const hashSha256 = crypto.createHash('sha256').update(signedBuffer).digest('hex'); + + created.push({ + id: crypto.randomUUID(), + userId, + slug: template.slug, + title: template.title, + version: template.version, + effectiveDate: template.effectiveDate, + createdAt: timestamp.toISOString(), + originalFilename, + originalPath, + signedFilename, + signedPath, + hashSha256, + }); + } + + await legalRepository.createMany(created); + + await auditService.logEvent({ + type: 'LEGAL_PACKAGE_SIGNED', + userId, + email: userEmail, + ip: context.ip, + userAgent: context.userAgent, + metadata: { + version: LEGAL_VERSION, + documents: created.map((item) => ({ slug: item.slug, title: item.title })), + }, + }); + + return created; +}; + +const listByUser = async (userId) => { + const items = await legalRepository.findByUser(userId); + return items.map(({ originalPath, signedPath, ...safe }) => safe); +}; + +const getOne = async (id, userId) => { + const item = await legalRepository.findById(id); + if (!item) { + const error = new Error('Legal document not found.'); + error.statusCode = 404; + throw error; + } + if (item.userId !== userId) { + const error = new Error('Access denied.'); + error.statusCode = 403; + throw error; + } + return item; +}; + +const getTemplates = async () => getLegalTemplates().map((template) => ({ + slug: template.slug, + title: template.title, + version: template.version, + effectiveDate: template.effectiveDate, + summary: template.summary, + sections: template.sections, +})); + +const getTemplatePreview = async (slug) => { + const template = getLegalTemplates().find((item) => item.slug === slug); + if (!template) { + const error = new Error('Legal template not found.'); + error.statusCode = 404; + throw error; + } + + const pdfBytes = await buildLegalPdfBytes({ + userName: 'Vista previa de registro', + userEmail: 'preview@linkedsign.local', + template, + }); + + return { + filename: `${sanitizeSlug(slug)}_preview.pdf`, + pdfBytes, + }; +}; + +module.exports = { + issueInitialLegalPackage, + listByUser, + getOne, + getTemplates, + getTemplatePreview, +}; diff --git a/backend/src/modules/legal/legal.templates.js b/backend/src/modules/legal/legal.templates.js new file mode 100644 index 0000000..38c1c77 --- /dev/null +++ b/backend/src/modules/legal/legal.templates.js @@ -0,0 +1,170 @@ +const env = require('../../config/env'); + +const LEGAL_VERSION = '1.1.0'; +const EFFECTIVE_DATE = '2026-04-07'; + +const buildControllerBlock = () => { + const { + controllerName, + platformName, + contactEmail, + contactPhone, + contactAddress, + website, + rightsEmail, + } = env.legal; + + return { + controllerName, + platformName, + contactEmail, + contactPhone, + contactAddress, + website, + rightsEmail, + }; +}; + +const getLegalTemplates = () => { + const controller = buildControllerBlock(); + + return [ + { + slug: 'terms-of-use', + title: `Términos de Uso de ${controller.platformName}`, + version: LEGAL_VERSION, + effectiveDate: EFFECTIVE_DATE, + summary: `Condiciones de acceso y uso de ${controller.platformName} como plataforma de gestión documental y firma electrónica.`, + sections: [ + { + heading: 'Identificación del prestador', + body: [ + `${controller.controllerName} opera ${controller.platformName} como plataforma tecnológica para gestión documental, firma electrónica, administración de certificados y conservación de evidencia técnica asociada.`, + `Canales de contacto del responsable: correo ${controller.contactEmail}, teléfono ${controller.contactPhone}, dirección ${controller.contactAddress} y sitio web ${controller.website}.`, + ], + }, + { + heading: 'Objeto y alcance', + body: [ + `Estos términos regulan el acceso y uso de ${controller.platformName}. La persona usuaria declara que utilizará el sistema de forma lícita, veraz, diligente y conforme a la normativa ecuatoriana aplicable.`, + 'El servicio está orientado a personas que cuentan con capacidad legal suficiente y, cuando corresponda, con un certificado digital válido y autorizado para su uso.', + ], + }, + { + heading: 'Responsabilidades de la persona usuaria', + body: [ + 'La persona usuaria es responsable del resguardo de sus credenciales, de la confidencialidad de su certificado digital y del contenido de los documentos que cargue o firme dentro del sistema.', + 'La persona usuaria declara que el archivo P12 o PFX cargado le pertenece o que cuenta con autorización suficiente para usarlo y que su utilización no vulnera derechos de terceros.', + ], + }, + { + heading: 'Uso de la firma electrónica y de la plataforma', + body: [ + `${controller.platformName} procesa documentos PDF, certificados digitales, metadatos de firma y evidencia técnica para habilitar firma electrónica visible y criptográfica, verificación y trazabilidad de operaciones.`, + 'La contraseña del certificado proporcionada durante el registro inicial se utiliza exclusivamente para firmar los documentos legales de alta y no se conserva como credencial de acceso ni como dato persistente del sistema.', + ], + }, + { + heading: 'Disponibilidad, conservación y soporte', + body: [ + `${controller.platformName} podrá conservar documentos firmados, evidencias técnicas, registros de auditoría, comprobantes de aceptación legal y demás soportes durante el tiempo necesario para prestar el servicio, atender reclamaciones y cumplir obligaciones legales aplicables.`, + `Las consultas, incidentes y solicitudes relacionadas con la cuenta, el servicio o los documentos legales pueden remitirse a ${controller.contactEmail}.`, + ], + }, + ], + }, + { + slug: 'privacy-policy', + title: 'Política de Privacidad y Aviso de Tratamiento de Datos', + version: LEGAL_VERSION, + effectiveDate: EFFECTIVE_DATE, + summary: `Información sobre tratamiento de datos personales conforme a la LOPDP para personas usuarias de ${controller.platformName}.`, + sections: [ + { + heading: 'Responsable del tratamiento', + body: [ + `El responsable del tratamiento es ${controller.controllerName}, operador de ${controller.platformName}.`, + `Datos de contacto del responsable: correo ${controller.contactEmail}, teléfono ${controller.contactPhone}, dirección ${controller.contactAddress} y sitio web ${controller.website}.`, + ], + }, + { + heading: 'Datos personales tratados', + body: [ + 'Se pueden tratar datos identificativos y de contacto, correo electrónico, credenciales de acceso cifradas, datos del certificado digital, dirección IP, agente de usuario, registros de auditoría, archivos cargados, documentos firmados, metadatos de firma y evidencia técnica vinculada al servicio.', + 'Durante el alta legal también se trata el archivo del certificado digital y la contraseña del certificado exclusivamente para validar el certificado y firmar los documentos legales iniciales.', + ], + }, + { + heading: 'Finalidades y base jurídica', + body: [ + `Los datos se tratan para crear y administrar la cuenta, autenticar a la persona usuaria, habilitar firma electrónica, administrar certificados, generar y conservar evidencia de operaciones, atender solicitudes, enviar notificaciones y cumplir obligaciones legales, regulatorias o contractuales.`, + 'La base jurídica del tratamiento podrá ser la ejecución de la relación precontractual o contractual, el cumplimiento de obligaciones legales y, cuando corresponda, el consentimiento expreso, libre, específico, informado e inequívoco de la persona titular.', + ], + }, + { + heading: 'Conservación y consecuencias de la entrega de datos', + body: [ + 'Los datos se conservarán mientras la cuenta permanezca activa y, posteriormente, durante el tiempo necesario para cumplir obligaciones legales, atender reclamaciones, resguardar evidencias y cumplir políticas de conservación documental y de seguridad.', + `Si la persona titular no proporciona los datos mínimos requeridos o no entrega un certificado digital válido para el alta legal inicial, ${controller.platformName} no podrá completar el registro ni habilitar las funciones de firma electrónica que dependan de dicho certificado.`, + ], + }, + { + heading: 'Destinatarios, encargados y transferencias', + body: [ + 'Los datos podrán ser tratados por proveedores tecnológicos que actúen por cuenta del responsable para servicios de hosting, almacenamiento, correo transaccional, seguridad, soporte o respaldo, siempre bajo instrucciones documentadas y medidas de seguridad adecuadas.', + 'No se realizan decisiones basadas únicamente en tratamientos automatizados con efectos jurídicos para la persona titular ni perfiles comerciales automatizados en el marco de este flujo de registro. Si existieran transferencias nacionales o internacionales necesarias para la prestación del servicio o por obligación legal, se realizarán con las salvaguardas exigidas por la normativa aplicable.', + ], + }, + { + heading: 'Derechos de la persona titular y canal de atención', + body: [ + 'La persona titular puede ejercer los derechos previstos en la LOPDP, incluyendo acceso, rectificación, actualización, eliminación, oposición, portabilidad, suspensión del tratamiento y revocatoria del consentimiento cuando sea jurídicamente procedente.', + `Las solicitudes para ejercicio de derechos, consultas o reclamos pueden enviarse a ${controller.rightsEmail}. ${controller.controllerName} atenderá dichas solicitudes conforme a los plazos y requisitos establecidos en la normativa aplicable.`, + ], + }, + ], + }, + { + slug: 'data-processing-consent', + title: 'Consentimiento Expreso para Tratamiento de Datos y Uso de Firma Electrónica', + version: LEGAL_VERSION, + effectiveDate: EFFECTIVE_DATE, + summary: 'Aceptación expresa del tratamiento de datos personales, del uso del certificado digital para el alta inicial y de la generación de evidencia legal del registro.', + sections: [ + { + heading: 'Declaración de consentimiento', + body: [ + `La persona usuaria declara de forma expresa, libre, específica, informada e inequívoca que ha leído y comprendido los Términos de Uso, la Política de Privacidad y este documento, y que acepta el tratamiento de sus datos personales por ${controller.controllerName} para operar dentro de ${controller.platformName}.`, + 'La persona usuaria autoriza el uso del certificado digital cargado exclusivamente para firmar los documentos legales del alta inicial, generar evidencia de aceptación y habilitar el flujo de registro dentro de la plataforma.', + ], + }, + { + heading: 'Uso del certificado y de la contraseña del certificado', + body: [ + 'La contraseña del archivo P12 o PFX se utilizará únicamente durante el proceso técnico de validación del certificado y firma de los documentos legales iniciales.', + 'La contraseña del certificado no será almacenada por la plataforma como credencial persistente ni será reutilizada automáticamente para otras operaciones posteriores.', + ], + }, + { + heading: 'Evidencia y entrega de copias', + body: [ + 'La aceptación queda respaldada por los documentos legales firmados electrónicamente, por el correo registrado, por la fecha y hora del registro, por la huella digital del archivo firmado y por los registros técnicos asociados a la operación.', + `La persona usuaria recibirá una copia de los documentos legales firmados en el correo registrado y podrá consultarlos posteriormente dentro de ${controller.platformName} en la sección Legal, en modo de solo lectura.`, + ], + }, + { + heading: 'Revocatoria y límites', + body: [ + `La persona titular podrá solicitar información adicional o ejercer sus derechos mediante ${controller.rightsEmail}. La revocatoria del consentimiento, cuando resulte jurídicamente procedente, no afectará la licitud de los tratamientos realizados con anterioridad ni la conservación obligatoria de evidencias y soportes legales.`, + ], + }, + ], + }, + ]; +}; + +module.exports = { + LEGAL_VERSION, + EFFECTIVE_DATE, + getLegalTemplates, +}; diff --git a/backend/src/modules/mail/mail.repository.js b/backend/src/modules/mail/mail.repository.js index b87d119..58d220a 100644 --- a/backend/src/modules/mail/mail.repository.js +++ b/backend/src/modules/mail/mail.repository.js @@ -31,7 +31,7 @@ const getTransporter = () => { return transporter; }; -const sendMail = async ({ to, subject, html, text }) => { +const sendMail = async ({ to, subject, html, text, attachments }) => { const activeTransporter = getTransporter(); const info = await activeTransporter.sendMail({ from: env.mail.from, @@ -39,6 +39,7 @@ const sendMail = async ({ to, subject, html, text }) => { subject, html, text, + attachments, }); return info; diff --git a/backend/src/modules/mail/mail.service.js b/backend/src/modules/mail/mail.service.js index 247d9a0..2e70282 100644 --- a/backend/src/modules/mail/mail.service.js +++ b/backend/src/modules/mail/mail.service.js @@ -1,7 +1,7 @@ const env = require('../../config/env'); const mailRepository = require('./mail.repository'); -const sendVerificationEmail = async ({ name, email, verificationToken }) => { +const sendVerificationEmail = async ({ name, email, verificationToken, attachments = [] }) => { const verificationUrl = `${env.frontendUrl.replace(/\/$/, '')}/auth/verify-email/${verificationToken}`; const subject = 'Confirma tu cuenta de LinkedSign'; const text = [ @@ -10,8 +10,10 @@ const sendVerificationEmail = async ({ name, email, verificationToken }) => { 'Confirma tu cuenta de LinkedSign usando este enlace:', verificationUrl, '', + attachments.length > 0 ? 'Adjuntamos una copia de los documentos legales firmados durante tu registro.' : '', + '', 'Si no solicitaste esta cuenta, puedes ignorar este mensaje.', - ].join('\n'); + ].filter(Boolean).join('\n'); const html = `

Confirma tu cuenta de LinkedSign

@@ -24,6 +26,7 @@ const sendVerificationEmail = async ({ name, email, verificationToken }) => {

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

${verificationUrl}

+ ${attachments.length > 0 ? '

Adjuntamos una copia de los documentos legales firmados durante tu registro inicial.

' : ''}

Si no solicitaste esta cuenta, puedes ignorar este mensaje.

`; @@ -33,6 +36,7 @@ const sendVerificationEmail = async ({ name, email, verificationToken }) => { subject, html, text, + attachments, }); if (!env.mail.user || !env.mail.pass) { diff --git a/backend/src/modules/signing/signing.service.js b/backend/src/modules/signing/signing.service.js index b071ce1..035ccfe 100644 --- a/backend/src/modules/signing/signing.service.js +++ b/backend/src/modules/signing/signing.service.js @@ -222,6 +222,8 @@ const signPdf = async ({ qrBackground = 'transparent', qrBorder = 'none', verificationUrl = null, + outputDir = null, + outputBaseName = null, }) => { const pdfBytes = await fs.readFile(pdfPath); const pdfDoc = await PDFDocument.load(pdfBytes, { ignoreEncryption: true }); @@ -293,9 +295,9 @@ const signPdf = async ({ const signedBuf = await signInstance.sign(pdfWithPlaceholder, signer); // ── Save ────────────────────────────────────────────────────────── - const destDir = await getSignedDocumentPath(); + const destDir = outputDir || await getSignedDocumentPath(); const ext = path.extname(pdfPath); - const base = path.basename(pdfPath, ext).replace(/^\d+-/, ''); + const base = outputBaseName || path.basename(pdfPath, ext).replace(/^\d+-/, ''); const nowForFilename = new Date(); const year = String(nowForFilename.getFullYear()); const month = String(nowForFilename.getMonth() + 1).padStart(2, '0'); @@ -303,7 +305,7 @@ const signPdf = async ({ const hours = String(nowForFilename.getHours()).padStart(2, '0'); const minutes = String(nowForFilename.getMinutes()).padStart(2, '0'); const dateSuffix = `${year}${month}${day}${hours}${minutes}`; - const signedFilename = `${base}_${dateSuffix}${ext}`; + const signedFilename = outputBaseName ? `${base}${ext}` : `${base}_${dateSuffix}${ext}`; const signedPath = path.join(destDir, signedFilename); await fs.writeFile(signedPath, signedBuf); diff --git a/backend/src/routes/index.js b/backend/src/routes/index.js index db9d8eb..feb1adf 100644 --- a/backend/src/routes/index.js +++ b/backend/src/routes/index.js @@ -4,6 +4,7 @@ const certificatesRoutes = require('../modules/certificates/certificates.routes' const downloadsRoutes = require('../modules/downloads/downloads.routes'); const documentsRoutes = require('../modules/documents/documents.routes'); const signaturesRoutes = require('../modules/signatures/signatures.routes'); +const legalRoutes = require('../modules/legal/legal.routes'); const usersRoutes = require('../modules/users/users.routes'); const healthRoutes = require('../modules/health/health.routes'); @@ -13,6 +14,7 @@ router.use('/auth', authRoutes); router.use('/certificates', certificatesRoutes); router.use('/downloads', downloadsRoutes); router.use('/documents', documentsRoutes); +router.use('/legal', legalRoutes); router.use('/signatures', signaturesRoutes); router.use('/users', usersRoutes); router.use('/health', healthRoutes); diff --git a/backend/src/utils/storagePath.js b/backend/src/utils/storagePath.js index 0c7d87c..508b74b 100644 --- a/backend/src/utils/storagePath.js +++ b/backend/src/utils/storagePath.js @@ -26,4 +26,22 @@ const getCertificatePath = async (userId) => { return fullPath; }; -module.exports = { getDocumentPath, getSignedDocumentPath, getCertificatePath }; +const getTempUploadPath = async (bucket = 'misc') => { + const fullPath = path.join(env.storagePath, 'tmp', bucket); + await fs.mkdir(fullPath, { recursive: true }); + return fullPath; +}; + +const getLegalDocumentPath = async (userId, variant = 'signed') => { + const fullPath = path.join(env.storagePath, 'legal', 'users', String(userId), variant); + await fs.mkdir(fullPath, { recursive: true }); + return fullPath; +}; + +module.exports = { + getDocumentPath, + getSignedDocumentPath, + getCertificatePath, + getTempUploadPath, + getLegalDocumentPath, +}; diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 1909cd3..3ae8bf0 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -13,6 +13,7 @@ import CertificatesPage from './pages/certificates/CertificatesPage'; import SignatureStylePage from './pages/signature-style/SignatureStylePage'; import SignaturesPage from './pages/signatures/SignaturesPage'; import VerifySignaturePage from './pages/verify/VerifySignaturePage'; +import LegalPage from './pages/legal/LegalPage'; export default function App() { return ( @@ -46,6 +47,10 @@ export default function App() { path="/signatures" element={} /> + } + /> } /> diff --git a/frontend/src/api/auth.js b/frontend/src/api/auth.js index 0e652c7..539e02c 100644 --- a/frontend/src/api/auth.js +++ b/frontend/src/api/auth.js @@ -1,6 +1,10 @@ import client from './client'; -export const register = (data) => client.post('/auth/register', data); +export const register = (data) => client.post('/auth/register', data, { + headers: { + 'Content-Type': 'multipart/form-data', + }, +}); export const login = (data) => client.post('/auth/login', data); export const logout = (refreshToken) => client.post('/auth/logout', { refreshToken }); export const getMe = () => client.get('/auth/me'); diff --git a/frontend/src/api/legal.js b/frontend/src/api/legal.js new file mode 100644 index 0000000..5504d9b --- /dev/null +++ b/frontend/src/api/legal.js @@ -0,0 +1,7 @@ +import client from './client'; + +export const listLegalDocuments = () => client.get('/legal'); + +export const downloadLegalDocument = (id) => client.get(`/legal/${id}/download`, { + responseType: 'blob', +}); diff --git a/frontend/src/api/public.js b/frontend/src/api/public.js index 50c47b8..3b0d3b7 100644 --- a/frontend/src/api/public.js +++ b/frontend/src/api/public.js @@ -3,3 +3,6 @@ import axios from 'axios'; const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:4000'; export const verifySignature = (code) => axios.get(`${API_URL}/signatures/verify/${code}`); +export const getLegalTemplates = () => axios.get(`${API_URL}/legal/templates`); +export const getLegalTemplatePreview = (slug) => + axios.get(`${API_URL}/legal/templates/${slug}/preview`, { responseType: 'blob' }); diff --git a/frontend/src/components/layout/Layout.jsx b/frontend/src/components/layout/Layout.jsx index 36bb993..e8e9600 100644 --- a/frontend/src/components/layout/Layout.jsx +++ b/frontend/src/components/layout/Layout.jsx @@ -1,7 +1,7 @@ import { useEffect, useState } from 'react'; import { Link, useNavigate, useLocation } from 'react-router-dom'; import { useAuth } from '../../contexts/AuthContext'; -import { FileText, Shield, PenTool, LayoutDashboard, LogOut, Palette, Menu, X, KeyRound, Search, Plus, Users, Settings, ScrollText } from 'lucide-react'; +import { FileText, Shield, PenTool, LayoutDashboard, LogOut, Palette, Menu, X, KeyRound, Search, Plus, Users, Settings, ScrollText, Scale } from 'lucide-react'; import ChangePasswordModal from '../ui/ChangePasswordModal'; const navSections = [ @@ -28,6 +28,7 @@ const topbarTitles = { '/certificates': 'Firmantes', '/signature-style': 'Configuración', '/signatures': 'Firmar ahora', + '/legal': 'Legal', }; export default function Layout({ children }) { @@ -118,6 +119,14 @@ export default function Layout({ children }) { Auditoría + setMobileMenuOpen(false)} + > + + Legal +
diff --git a/frontend/src/index.css b/frontend/src/index.css index 5fdcda6..f9670b7 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -431,6 +431,185 @@ select { color: var(--gray-500); } +.legal-register-box { + border: 1px solid var(--gray-300); + border-radius: 12px; + padding: 14px; + background: #f8fbff; + display: grid; + gap: 12px; +} + +.legal-register-header { + display: flex; + gap: 10px; + align-items: flex-start; + color: var(--c-text); +} + +.legal-register-header p { + margin-top: 3px; + color: var(--c-muted); + font-size: 12px; +} + +.legal-register-list { + display: grid; + gap: 8px; +} + +.legal-template-row { + border: 1px solid var(--gray-300); + border-radius: 10px; + padding: 12px 14px; + background: #fff; +} + +.legal-template-stack { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 6px; +} + +.legal-template-button { + border: none; + border-radius: 0; + padding: 0; + background: transparent; + color: var(--c-text); + display: flex; + align-items: flex-start; + gap: 8px; + cursor: pointer; + text-align: left; +} + +.legal-template-button svg { + flex: 0 0 15px; + width: 15px; + height: 15px; + margin-top: 2px; +} + +.legal-template-button:hover { + color: var(--primary); +} + +.legal-template-button span { + font-weight: 600; + text-decoration: underline; + text-underline-offset: 2px; +} + +.legal-template-version { + display: inline-flex; + align-items: center; + gap: 5px; + color: var(--c-muted); + font-weight: 500; + font-size: 12px; +} + +.legal-template-version svg { + flex: 0 0 12px; + width: 12px; + height: 12px; +} + +.legal-template-check { + display: flex; + align-items: center; + gap: 8px; + color: var(--c-text); + font-size: 12px; + white-space: nowrap; +} + +.legal-template-check input { + accent-color: var(--primary); + flex: 0 0 15px; + width: 15px; + height: 15px; + opacity: 1; +} + +.legal-template-check.is-disabled { + opacity: 1; + cursor: not-allowed; +} + +.legal-template-check span { + color: var(--c-text); +} + +.legal-template-check.is-disabled span { + color: var(--c-text); +} + +.legal-register-status { + display: flex; + align-items: center; + gap: 8px; + color: var(--c-muted); + font-size: 12px; +} + +.btn[disabled] { + opacity: 0.55; + cursor: not-allowed; +} + +.legal-register-status.is-complete { + color: var(--success); +} + +.legal-document-modal { + background: #fff; + border-radius: 12px; + width: min(840px, 95vw); + max-height: 92vh; + display: flex; + flex-direction: column; + box-shadow: 0 24px 80px rgba(0, 0, 0, 0.35); + overflow: hidden; +} + +.legal-document-body { + flex: 1; + overflow-y: auto; + display: flex; + justify-content: center; + padding: 16px; + background: var(--gray-100); +} + +.legal-document-meta { + display: flex; + gap: 10px; + flex-wrap: wrap; + margin-bottom: 14px; + font-size: 11px; + color: var(--gray-500); +} + +.legal-document-summary { + margin-bottom: 16px; + color: var(--gray-700); + font-size: 13px; + line-height: 1.6; +} + +.legal-document-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 12px 16px; + border-top: 1px solid var(--gray-200); + background: var(--gray-50); +} + /* ─── Buttons ────────────────────────────────────────────────── */ .btn { display: inline-flex; align-items: center; gap: 6px; @@ -698,6 +877,12 @@ select { gap: 14px; } +.legal-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 14px; +} + .cert-card { background: #fff; border: 1px solid var(--gray-200); @@ -736,6 +921,33 @@ select { .form-card h3 { font-size: 14px; font-weight: 500; margin-bottom: 16px; color: var(--c-text); } .form-card form { display: flex; flex-direction: column; gap: 14px; } +.legal-summary-card { + display: flex; + align-items: flex-start; + gap: 12px; + margin-bottom: 16px; +} + +.legal-summary-icon { + width: 40px; + height: 40px; + border-radius: 999px; + display: inline-flex; + align-items: center; + justify-content: center; + background: var(--c-info-bg); + color: var(--c-primary); + flex-shrink: 0; +} + +.legal-card { + min-height: 100%; +} + +.legal-footer { + gap: 8px; +} + .form-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 8px; } /* ─── Sign page ──────────────────────────────────────────────── */ diff --git a/frontend/src/pages/auth/RegisterPage.jsx b/frontend/src/pages/auth/RegisterPage.jsx index 6450fbf..a181fd4 100644 --- a/frontend/src/pages/auth/RegisterPage.jsx +++ b/frontend/src/pages/auth/RegisterPage.jsx @@ -1,9 +1,46 @@ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { useNavigate, Link } from 'react-router-dom'; import { register } from '../../api/auth'; -import { PenTool } from 'lucide-react'; +import { CheckSquare, FileCheck2, FileText, PenTool, ShieldCheck, Tag, X } from 'lucide-react'; +import { Document, Page, pdfjs } from 'react-pdf'; +import { getLegalTemplatePreview, getLegalTemplates } from '../../api/public'; import toast from 'react-hot-toast'; +import 'react-pdf/dist/Page/AnnotationLayer.css'; +import 'react-pdf/dist/Page/TextLayer.css'; + +pdfjs.GlobalWorkerOptions.workerSrc = new URL( + 'pdfjs-dist/build/pdf.worker.min.mjs', + import.meta.url, +).toString(); + +const FALLBACK_LEGAL_TEMPLATES = [ + { + slug: 'terms-of-use', + title: 'Términos de Uso de LinkedSign', + version: '1.1.0', + effectiveDate: '2026-04-07', + summary: 'Condiciones de acceso y uso de LinkedSign.', + sections: [], + }, + { + slug: 'privacy-policy', + title: 'Política de Privacidad y Aviso de Tratamiento de Datos', + version: '1.1.0', + effectiveDate: '2026-04-07', + summary: 'Información sobre tratamiento de datos personales conforme a la LOPDP.', + sections: [], + }, + { + slug: 'data-processing-consent', + title: 'Consentimiento Expreso para Tratamiento de Datos y Uso de Firma Electrónica', + version: '1.1.0', + effectiveDate: '2026-04-07', + summary: 'Aceptación expresa del tratamiento de datos y del uso del certificado para el alta inicial.', + sections: [], + }, +]; + const getPasswordChecks = (password = '') => ([ { label: 'Al menos 8 caracteres', valid: password.length >= 8 }, { label: 'Una mayúscula', valid: /[A-Z]/.test(password) }, @@ -14,12 +51,68 @@ const getPasswordChecks = (password = '') => ([ export default function RegisterPage() { const navigate = useNavigate(); - const [form, setForm] = useState({ email: '', password: '', confirmPassword: '' }); + const [form, setForm] = useState({ + email: '', + password: '', + confirmPassword: '', + certPassword: '', + consentAccepted: false, + }); + const [certificateFile, setCertificateFile] = useState(null); + const [legalTemplates, setLegalTemplates] = useState([]); + const [openedTemplates, setOpenedTemplates] = useState({}); + const [acceptedTemplates, setAcceptedTemplates] = useState({}); + const [activeTemplate, setActiveTemplate] = useState(null); + const [viewerBlob, setViewerBlob] = useState(null); + const [loadingViewer, setLoadingViewer] = useState(false); + const [numPages, setNumPages] = useState(null); + const [currentPage, setCurrentPage] = useState(1); const [loading, setLoading] = useState(false); const passwordChecks = getPasswordChecks(form.password); const isPasswordStrong = passwordChecks.every((check) => check.valid); + const templatesToRender = legalTemplates.length > 0 ? legalTemplates : FALLBACK_LEGAL_TEMPLATES; + const allLegalAccepted = + templatesToRender.length > 0 && templatesToRender.every((template) => acceptedTemplates[template.slug]); - const handleChange = (e) => setForm((f) => ({ ...f, [e.target.name]: e.target.value })); + useEffect(() => { + getLegalTemplates() + .then(({ data }) => setLegalTemplates(data.data || [])) + .catch(() => { + setLegalTemplates([]); + }); + }, []); + + const handleChange = (e) => { + const { name, value, checked, type } = e.target; + setForm((f) => ({ ...f, [name]: type === 'checkbox' ? checked : value })); + }; + + const handleOpenTemplate = (template) => { + setActiveTemplate(template); + setOpenedTemplates((current) => ({ ...current, [template.slug]: true })); + setCurrentPage(1); + setNumPages(null); + setLoadingViewer(true); + getLegalTemplatePreview(template.slug) + .then(({ data }) => setViewerBlob(data)) + .catch(() => { + setViewerBlob(null); + toast.error('No se pudo abrir el documento legal'); + }) + .finally(() => setLoadingViewer(false)); + }; + + const handleTemplateAcceptance = (slug, checked) => { + setAcceptedTemplates((current) => ({ ...current, [slug]: checked })); + }; + + const closeViewer = () => { + setActiveTemplate(null); + setViewerBlob(null); + setLoadingViewer(false); + setNumPages(null); + setCurrentPage(1); + }; const handleSubmit = async (e) => { e.preventDefault(); @@ -31,13 +124,29 @@ export default function RegisterPage() { toast.error('Las contraseñas no coinciden'); return; } + if (!certificateFile) { + toast.error('Debes cargar tu certificado P12/PFX'); + return; + } + if (!form.certPassword) { + toast.error('Debes ingresar la contraseña del certificado'); + return; + } + if (!allLegalAccepted) { + toast.error('Debes leer y aceptar cada documento legal para completar el registro'); + return; + } setLoading(true); try { - await register({ - email: form.email, - password: form.password, - }); - toast.success('Cuenta creada. Revisa tu correo para confirmar tu usuario.'); + const payload = new FormData(); + payload.append('email', form.email); + payload.append('password', form.password); + payload.append('certPassword', form.certPassword); + payload.append('consentAccepted', 'true'); + payload.append('certificateFile', certificateFile); + + await register(payload); + toast.success('Cuenta creada. Revisa tu correo para confirmar tu usuario y recibir los documentos legales firmados.'); navigate('/login'); } catch (err) { toast.error(err.response?.data?.error || 'Error al registrarse'); @@ -78,7 +187,74 @@ export default function RegisterPage() {
- + + + Versión {template.version} + + + + + ))} + +
+ + + {allLegalAccepted + ? 'Todos los documentos legales fueron leídos y aceptados.' + : 'Debes abrir y aceptar cada documento para continuar.'} + +
+ + @@ -86,6 +262,61 @@ export default function RegisterPage() { ¿Ya tienes cuenta? Iniciar sesión

+ {activeTemplate && ( +
+
event.stopPropagation()}> +
+
+ + {activeTemplate.title} +
+
+ {numPages && ( + <> + + {currentPage} / {numPages} + + + )} + +
+
+
+ {loadingViewer ? ( +
+ ) : viewerBlob ? ( + setNumPages(totalPages)} + loading={
} + > + + + ) : ( +
No se pudo cargar la vista previa.
+ )} +
+
+
+ Versión {activeTemplate.version} + Vigente desde {activeTemplate.effectiveDate} +
+ +
+
+
+ )}
); } + const closeViewer = () => { + setActiveTemplate(null); + setViewerBlob(null); + setLoadingViewer(false); + setNumPages(null); + setCurrentPage(1); + }; diff --git a/frontend/src/pages/legal/LegalPage.jsx b/frontend/src/pages/legal/LegalPage.jsx new file mode 100644 index 0000000..e828114 --- /dev/null +++ b/frontend/src/pages/legal/LegalPage.jsx @@ -0,0 +1,160 @@ +import { useEffect, useState } from 'react'; +import { Download, Eye, FileCheck, FileText, Shield, X } from 'lucide-react'; +import toast from 'react-hot-toast'; +import { Document, Page, pdfjs } from 'react-pdf'; +import { listLegalDocuments, downloadLegalDocument } from '../../api/legal'; + +import 'react-pdf/dist/Page/AnnotationLayer.css'; +import 'react-pdf/dist/Page/TextLayer.css'; + +pdfjs.GlobalWorkerOptions.workerSrc = new URL( + 'pdfjs-dist/build/pdf.worker.min.mjs', + import.meta.url, +).toString(); + +const formatDate = (value) => new Date(value).toLocaleString('es-EC'); + +export default function LegalPage() { + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(true); + const [viewerBlob, setViewerBlob] = useState(null); + const [viewerTitle, setViewerTitle] = useState(''); + const [numPages, setNumPages] = useState(null); + const [currentPage, setCurrentPage] = useState(1); + const [loadingViewer, setLoadingViewer] = useState(false); + + useEffect(() => { + listLegalDocuments() + .then(({ data }) => setItems(data.data || [])) + .catch(() => toast.error('No se pudieron cargar los documentos legales')) + .finally(() => setLoading(false)); + }, []); + + const handleView = async (id) => { + try { + setLoadingViewer(true); + setCurrentPage(1); + setNumPages(null); + const { data } = await downloadLegalDocument(id); + setViewerBlob(data); + } catch { + toast.error('No se pudo abrir el documento legal'); + } finally { + setLoadingViewer(false); + } + }; + + const handleDownload = async (id, filename) => { + try { + const { data } = await downloadLegalDocument(id); + const blobUrl = URL.createObjectURL(new Blob([data], { type: 'application/pdf' })); + const anchor = document.createElement('a'); + anchor.href = blobUrl; + anchor.download = filename; + anchor.click(); + setTimeout(() => URL.revokeObjectURL(blobUrl), 10_000); + } catch { + toast.error('No se pudo descargar el documento legal'); + } + }; + + const closeViewer = () => { + setViewerBlob(null); + setViewerTitle(''); + }; + + return ( +
+ {(viewerBlob || loadingViewer) && ( +
+
event.stopPropagation()}> +
+ {viewerTitle} +
+ {numPages && ( + <> + + {currentPage} / {numPages} + + + )} + +
+
+
+ {loadingViewer ? ( +
+ ) : ( + setNumPages(totalPages)} + loading={
} + > + + + )} +
+
+
+ )} + +
+
+

Legal

+

Documentos legales firmados durante tu registro. Puedes verlos y descargarlos, pero no eliminarlos.

+
+
+ +
+
+ +
+
+

Consentimiento legal de registro

+

+ Los documentos de esta sección fueron firmados con tu propio certificado digital en el primer registro. + La contraseña del certificado se usó solo en ese momento y no fue almacenada. +

+
+
+ + {loading ? ( +
+ ) : items.length === 0 ? ( +
+ +

Aún no hay documentos legales asociados a tu cuenta.

+
+ ) : ( +
+ {items.map((item) => ( +
+
+ + {item.title} + Firmado +
+
+

Versión: {item.version}

+

Vigente desde: {item.effectiveDate}

+

Firmado el: {formatDate(item.createdAt)}

+

Archivo: {item.signedFilename}

+
+
+ + +
+
+ ))} +
+ )} +
+ ); +}