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() {
Confirmar contraseña
-
+
+ Certificado P12/PFX
+ setCertificateFile(e.target.files?.[0] || null)} required />
+
+
+ Contraseña del certificado
+
+
+ Esta contraseña solo se usa para firmar los documentos legales del registro inicial. No se guarda.
+
+
+
+
+
+
+
Documentos legales del registro
+
Debes abrir cada documento, leerlo y aceptarlo. Se firmarán automáticamente al final de cada documento con tu propio certificado.
+
+
+
+ {templatesToRender.map((template) => (
+
+
+ handleOpenTemplate(template)}
+ >
+
+ {template.title}
+
+
+
+ Versión {template.version}
+
+
+ {
+ if (!openedTemplates[template.slug]) {
+ event.preventDefault();
+ toast.error('Primero debes abrir y leer este documento');
+ return;
+ }
+ handleTemplateAcceptance(template.slug, event.target.checked);
+ }}
+ />
+ He leído y acepto
+
+
+
+ ))}
+
+
+
+
+ {allLegalAccepted
+ ? 'Todos los documentos legales fueron leídos y aceptados.'
+ : 'Debes abrir y aceptar cada documento para continuar.'}
+
+
+
+
{loading ? 'Registrando...' : 'Crear cuenta'}
@@ -86,6 +262,61 @@ export default function RegisterPage() {
¿Ya tienes cuenta? Iniciar sesión
+ {activeTemplate && (
+
+
event.stopPropagation()}>
+
+
+
+ {activeTemplate.title}
+
+
+ {numPages && (
+ <>
+ setCurrentPage((page) => page - 1)}>‹
+ {currentPage} / {numPages}
+ = numPages} onClick={() => setCurrentPage((page) => page + 1)}>›
+ >
+ )}
+
+
+
+
+
+
+ {loadingViewer ? (
+
+ ) : viewerBlob ? (
+
setNumPages(totalPages)}
+ loading={}
+ >
+
+
+ ) : (
+
No se pudo cargar la vista previa.
+ )}
+
+
+
+ Versión {activeTemplate.version}
+ Vigente desde {activeTemplate.effectiveDate}
+
+
+ Cerrar
+
+
+
+
+ )}
);
}
+ 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 && (
+ <>
+ setCurrentPage((page) => page - 1)}>‹
+ {currentPage} / {numPages}
+ = numPages} onClick={() => setCurrentPage((page) => page + 1)}>›
+ >
+ )}
+
+
+
+
+ {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}
+
+
+ {
+ setViewerTitle(item.title);
+ handleView(item.id);
+ }}>
+ Ver
+
+ handleDownload(item.id, item.signedFilename)}>
+ Descargar
+
+
+
+ ))}
+
+ )}
+
+ );
+}