se agrega los requerimientos de la LODPD con firma de documentos

This commit is contained in:
2026-04-07 15:14:44 -05:00
parent 408a9b4435
commit 46a11728b8
31 changed files with 1432 additions and 52 deletions
@@ -16,8 +16,15 @@ interface LinkedSignApiService {
@POST("auth/login") @POST("auth/login")
suspend fun login(@Body body: LoginRequest): ApiEnvelope<LoginData> suspend fun login(@Body body: LoginRequest): ApiEnvelope<LoginData>
@Multipart
@POST("auth/register") @POST("auth/register")
suspend fun register(@Body body: RegisterRequest): ApiEnvelope<RegisterData> suspend fun register(
@Part("email") email: RequestBody,
@Part("password") password: RequestBody,
@Part("certPassword") certPassword: RequestBody,
@Part("consentAccepted") consentAccepted: RequestBody,
@Part certificateFile: MultipartBody.Part
): ApiEnvelope<RegisterData>
@POST("auth/refresh") @POST("auth/refresh")
suspend fun refresh(@Body body: RefreshRequest): ApiEnvelope<RefreshData> suspend fun refresh(@Body body: RefreshRequest): ApiEnvelope<RefreshData>
@@ -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 { 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))
} }
} }
@@ -43,11 +43,6 @@ data class LoginData(
val user: SessionUser val user: SessionUser
) )
data class RegisterRequest(
val email: String,
val password: String
)
data class RegisterData( data class RegisterData(
val message: String?, val message: String?,
val userId: Int? val userId: Int?
@@ -368,9 +368,9 @@ fun LinkedSignApp(viewModel: MainViewModel, appContext: Context, activity: Compo
} }
} }
}, },
onRegister = { email, password -> onRegister = { email, password, certPassword, certificateUri, certificateName ->
scope.launch { scope.launch {
runCatching { viewModel.register(email, password) } runCatching { viewModel.register(email, password, certPassword, certificateUri, certificateName) }
.onSuccess { .onSuccess {
authMode = AuthMode.VerifyNotice authMode = AuthMode.VerifyNotice
} }
@@ -529,7 +529,7 @@ private fun AuthFlow(
verifyEmail: String?, verifyEmail: String?,
onConsumeVerifyNotice: () -> Unit, onConsumeVerifyNotice: () -> Unit,
onLogin: (String, String) -> Unit, onLogin: (String, String) -> Unit,
onRegister: (String, String) -> Unit, onRegister: (String, String, String, Uri, String) -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
Column( Column(
@@ -780,11 +780,25 @@ private fun LoginCard(onSubmit: (String, String) -> Unit, onGoRegister: () -> Un
} }
@Composable @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 email by rememberSaveable { mutableStateOf("") }
var password by rememberSaveable { mutableStateOf("") } var password by rememberSaveable { mutableStateOf("") }
var confirmPassword by rememberSaveable { mutableStateOf("") } var confirmPassword by rememberSaveable { mutableStateOf("") }
var certPassword by rememberSaveable { mutableStateOf("") }
var consentAccepted by rememberSaveable { mutableStateOf(false) }
var certificateUri by rememberSaveable { mutableStateOf<Uri?>(null) }
var certificateName by rememberSaveable { mutableStateOf("") }
val passwordProblems = passwordIssues(password) 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( ElevatedCard(
shape = RoundedCornerShape(24.dp), shape = RoundedCornerShape(24.dp),
@@ -810,7 +824,7 @@ private fun RegisterCard(onSubmit: (String, String) -> Unit, onGoLogin: () -> Un
} }
Column { Column {
Text("Alta protegida", color = BrandDark, fontWeight = FontWeight.SemiBold, fontSize = AppFontSizes.base) 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) AppTextField(value = password, onValueChange = { password = it }, label = "Contraseña", isPassword = true)
PasswordChecklist(password = password) PasswordChecklist(password = password)
AppTextField(value = confirmPassword, onValueChange = { confirmPassword = it }, label = "Confirmar contraseña", isPassword = true) 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()) { if (passwordProblems.isNotEmpty() && password.isNotBlank()) {
Surface(color = WarningBg, shape = RoundedCornerShape(10.dp)) { 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) 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 { when {
passwordProblems.isNotEmpty() -> Unit passwordProblems.isNotEmpty() -> Unit
password != confirmPassword -> 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), modifier = Modifier.fillMaxWidth().height(50.dp),
@@ -63,8 +63,8 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
uiState = buildState(session, cache, loading = false) uiState = buildState(session, cache, loading = false)
} }
suspend fun register(email: String, password: String) { suspend fun register(email: String, password: String, certPassword: String, certificateUri: Uri, certificateName: String) {
repository.register(email, password) repository.register(email, password, certPassword, certificateUri, certificateName)
uiState = uiState.copy(emailForVerificationNotice = email) uiState = uiState.copy(emailForVerificationNotice = email)
} }
+7
View File
@@ -17,5 +17,12 @@ MAIL_TLS_REJECT_UNAUTHORIZED=true
MAIL_USER= MAIL_USER=
MAIL_PASS= MAIL_PASS=
MAIL_FROM=noreply@linkedsign.local 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 FRONTEND_URL=http://localhost:5173
ANDROID_APK_PATH= ANDROID_APK_PATH=
+10
View File
@@ -39,6 +39,16 @@ const env = {
from: process.env.MAIL_FROM || 'noreply@linkedsign.local', 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', frontendUrl: process.env.FRONTEND_URL || 'http://localhost:5173',
androidApkPath: androidApkPath:
process.env.ANDROID_APK_PATH || path.resolve(process.cwd(), '..', 'android', 'apk', 'LinkedSign-debug.apk'), process.env.ANDROID_APK_PATH || path.resolve(process.cwd(), '..', 'android', 'apk', 'LinkedSign-debug.apk'),
+28 -2
View File
@@ -1,6 +1,6 @@
const path = require('path'); const path = require('path');
const multer = require('multer'); const multer = require('multer');
const { getDocumentPath, getCertificatePath } = require('../utils/storagePath'); const { getDocumentPath, getCertificatePath, getTempUploadPath } = require('../utils/storagePath');
const documentStorage = multer.diskStorage({ const documentStorage = multer.diskStorage({
destination: async (req, file, cb) => { 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({ const uploadDocument = multer({
storage: documentStorage, storage: documentStorage,
limits: { fileSize: 20 * 1024 * 1024 }, 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 };
+12 -1
View File
@@ -9,7 +9,18 @@ const getContext = (req) => ({
const register = async (req, res, next) => { const register = async (req, res, next) => {
try { 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, { return sendSuccess(res, {
message: 'Registration successful. Please check your email to verify your account.', message: 'Registration successful. Please check your email to verify your account.',
userId: result.userId, userId: result.userId,
@@ -98,6 +98,10 @@ const updatePassword = async (userId, passwordHash) => {
); );
}; };
const deleteUser = async (userId) => {
await db.execute('DELETE FROM users WHERE id = ?', [userId]);
};
module.exports = { module.exports = {
findByEmail, findByEmail,
findById, findById,
@@ -112,4 +116,5 @@ module.exports = {
setResetToken, setResetToken,
findByResetToken, findByResetToken,
updatePassword, updatePassword,
deleteUser,
}; };
+3 -2
View File
@@ -1,11 +1,12 @@
const express = require('express'); const express = require('express');
const authController = require('./auth.controller'); 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 authenticate = require('../../middlewares/authenticate');
const { uploadTempCertificate } = require('../../config/multer');
const router = express.Router(); 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('/login', validate(loginSchema), authController.login);
router.post('/refresh', validate(refreshSchema), authController.refresh); router.post('/refresh', validate(refreshSchema), authController.refresh);
router.post('/logout', authController.logout); router.post('/logout', authController.logout);
+57 -7
View File
@@ -5,7 +5,12 @@ const passwordChangeRepository = require('./password-change.repository');
const auditService = require('../audit/audit.service'); const auditService = require('../audit/audit.service');
const usersService = require('../users/users.service'); const usersService = require('../users/users.service');
const mailService = require('../mail/mail.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 { signAccess, signRefresh } = require('../../utils/jwt');
const fs = require('fs/promises');
const path = require('path');
const SALT_ROUNDS = 12; 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); const existing = await authRepository.findByEmail(email);
if (existing) { if (existing) {
const err = new Error('Email already registered.'); const err = new Error('Email already registered.');
@@ -57,17 +62,56 @@ const register = async ({ email, password }) => {
verificationToken, verificationToken,
verificationTokenExpires, verificationTokenExpires,
}); });
let mailResult;
let uploadedCertificate = null;
let legalDocuments = [];
try { 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, name,
email, email,
verificationToken, verificationToken,
attachments,
}); });
} catch (error) { } 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.'); await authRepository.deleteUser(userId);
err.statusCode = 503; try {
err.code = 'MAIL_SEND_FAILED'; 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; throw err;
} }
@@ -77,10 +121,16 @@ const register = async ({ email, password }) => {
email, email,
metadata: { metadata: {
verificationEmailSent: true, 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 = {}) => { const login = async ({ email, password }, context = {}) => {
+48 -5
View File
@@ -17,10 +17,17 @@ const isStrongPassword = (value, helpers) => {
return value; return value;
}; };
const registerSchema = Joi.object({ const passwordRuleCheckers = [
email: Joi.string().email().required(), { test: (value) => value.length >= 8, label: 'al menos 8 caracteres' },
password: Joi.string().max(100).required().custom(isStrongPassword), { 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({ const loginSchema = Joi.object({
email: Joi.string().email().required(), email: Joi.string().email().required(),
@@ -36,6 +43,42 @@ const changePasswordSchema = Joi.object({
newPassword: Joi.string().max(100).required().custom(isStrongPassword), 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 validate = (schema) => (req, res, next) => {
const { error } = schema.validate(req.body, { abortEarly: false }); const { error } = schema.validate(req.body, { abortEarly: false });
if (error) { if (error) {
@@ -45,4 +88,4 @@ const validate = (schema) => (req, res, next) => {
next(); next();
}; };
module.exports = { validate, registerSchema, loginSchema, refreshSchema, changePasswordSchema }; module.exports = { validate, validateRegisterMultipart, loginSchema, refreshSchema, changePasswordSchema };
@@ -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,
};
@@ -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,
};
+13
View File
@@ -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;
+225
View File
@@ -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,
};
@@ -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,
};
+2 -1
View File
@@ -31,7 +31,7 @@ const getTransporter = () => {
return transporter; return transporter;
}; };
const sendMail = async ({ to, subject, html, text }) => { const sendMail = async ({ to, subject, html, text, attachments }) => {
const activeTransporter = getTransporter(); const activeTransporter = getTransporter();
const info = await activeTransporter.sendMail({ const info = await activeTransporter.sendMail({
from: env.mail.from, from: env.mail.from,
@@ -39,6 +39,7 @@ const sendMail = async ({ to, subject, html, text }) => {
subject, subject,
html, html,
text, text,
attachments,
}); });
return info; return info;
+6 -2
View File
@@ -1,7 +1,7 @@
const env = require('../../config/env'); const env = require('../../config/env');
const mailRepository = require('./mail.repository'); 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 verificationUrl = `${env.frontendUrl.replace(/\/$/, '')}/auth/verify-email/${verificationToken}`;
const subject = 'Confirma tu cuenta de LinkedSign'; const subject = 'Confirma tu cuenta de LinkedSign';
const text = [ const text = [
@@ -10,8 +10,10 @@ const sendVerificationEmail = async ({ name, email, verificationToken }) => {
'Confirma tu cuenta de LinkedSign usando este enlace:', 'Confirma tu cuenta de LinkedSign usando este enlace:',
verificationUrl, verificationUrl,
'', '',
attachments.length > 0 ? 'Adjuntamos una copia de los documentos legales firmados durante tu registro.' : '',
'',
'Si no solicitaste esta cuenta, puedes ignorar este mensaje.', 'Si no solicitaste esta cuenta, puedes ignorar este mensaje.',
].join('\n'); ].filter(Boolean).join('\n');
const html = ` const html = `
<div style="font-family: Arial, sans-serif; line-height: 1.5; color: #111827;"> <div style="font-family: Arial, sans-serif; line-height: 1.5; color: #111827;">
<h2 style="margin-bottom: 12px;">Confirma tu cuenta de LinkedSign</h2> <h2 style="margin-bottom: 12px;">Confirma tu cuenta de LinkedSign</h2>
@@ -24,6 +26,7 @@ const sendVerificationEmail = async ({ name, email, verificationToken }) => {
</p> </p>
<p>Si el botón no funciona, copia y pega este enlace en tu navegador:</p> <p>Si el botón no funciona, copia y pega este enlace en tu navegador:</p>
<p><a href="${verificationUrl}">${verificationUrl}</a></p> <p><a href="${verificationUrl}">${verificationUrl}</a></p>
${attachments.length > 0 ? '<p>Adjuntamos una copia de los documentos legales firmados durante tu registro inicial.</p>' : ''}
<p>Si no solicitaste esta cuenta, puedes ignorar este mensaje.</p> <p>Si no solicitaste esta cuenta, puedes ignorar este mensaje.</p>
</div> </div>
`; `;
@@ -33,6 +36,7 @@ const sendVerificationEmail = async ({ name, email, verificationToken }) => {
subject, subject,
html, html,
text, text,
attachments,
}); });
if (!env.mail.user || !env.mail.pass) { if (!env.mail.user || !env.mail.pass) {
@@ -222,6 +222,8 @@ const signPdf = async ({
qrBackground = 'transparent', qrBackground = 'transparent',
qrBorder = 'none', qrBorder = 'none',
verificationUrl = null, verificationUrl = null,
outputDir = null,
outputBaseName = null,
}) => { }) => {
const pdfBytes = await fs.readFile(pdfPath); const pdfBytes = await fs.readFile(pdfPath);
const pdfDoc = await PDFDocument.load(pdfBytes, { ignoreEncryption: true }); const pdfDoc = await PDFDocument.load(pdfBytes, { ignoreEncryption: true });
@@ -293,9 +295,9 @@ const signPdf = async ({
const signedBuf = await signInstance.sign(pdfWithPlaceholder, signer); const signedBuf = await signInstance.sign(pdfWithPlaceholder, signer);
// ── Save ────────────────────────────────────────────────────────── // ── Save ──────────────────────────────────────────────────────────
const destDir = await getSignedDocumentPath(); const destDir = outputDir || await getSignedDocumentPath();
const ext = path.extname(pdfPath); 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 nowForFilename = new Date();
const year = String(nowForFilename.getFullYear()); const year = String(nowForFilename.getFullYear());
const month = String(nowForFilename.getMonth() + 1).padStart(2, '0'); 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 hours = String(nowForFilename.getHours()).padStart(2, '0');
const minutes = String(nowForFilename.getMinutes()).padStart(2, '0'); const minutes = String(nowForFilename.getMinutes()).padStart(2, '0');
const dateSuffix = `${year}${month}${day}${hours}${minutes}`; 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); const signedPath = path.join(destDir, signedFilename);
await fs.writeFile(signedPath, signedBuf); await fs.writeFile(signedPath, signedBuf);
+2
View File
@@ -4,6 +4,7 @@ const certificatesRoutes = require('../modules/certificates/certificates.routes'
const downloadsRoutes = require('../modules/downloads/downloads.routes'); const downloadsRoutes = require('../modules/downloads/downloads.routes');
const documentsRoutes = require('../modules/documents/documents.routes'); const documentsRoutes = require('../modules/documents/documents.routes');
const signaturesRoutes = require('../modules/signatures/signatures.routes'); const signaturesRoutes = require('../modules/signatures/signatures.routes');
const legalRoutes = require('../modules/legal/legal.routes');
const usersRoutes = require('../modules/users/users.routes'); const usersRoutes = require('../modules/users/users.routes');
const healthRoutes = require('../modules/health/health.routes'); const healthRoutes = require('../modules/health/health.routes');
@@ -13,6 +14,7 @@ router.use('/auth', authRoutes);
router.use('/certificates', certificatesRoutes); router.use('/certificates', certificatesRoutes);
router.use('/downloads', downloadsRoutes); router.use('/downloads', downloadsRoutes);
router.use('/documents', documentsRoutes); router.use('/documents', documentsRoutes);
router.use('/legal', legalRoutes);
router.use('/signatures', signaturesRoutes); router.use('/signatures', signaturesRoutes);
router.use('/users', usersRoutes); router.use('/users', usersRoutes);
router.use('/health', healthRoutes); router.use('/health', healthRoutes);
+19 -1
View File
@@ -26,4 +26,22 @@ const getCertificatePath = async (userId) => {
return fullPath; 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,
};
+5
View File
@@ -13,6 +13,7 @@ import CertificatesPage from './pages/certificates/CertificatesPage';
import SignatureStylePage from './pages/signature-style/SignatureStylePage'; import SignatureStylePage from './pages/signature-style/SignatureStylePage';
import SignaturesPage from './pages/signatures/SignaturesPage'; import SignaturesPage from './pages/signatures/SignaturesPage';
import VerifySignaturePage from './pages/verify/VerifySignaturePage'; import VerifySignaturePage from './pages/verify/VerifySignaturePage';
import LegalPage from './pages/legal/LegalPage';
export default function App() { export default function App() {
return ( return (
@@ -46,6 +47,10 @@ export default function App() {
path="/signatures" path="/signatures"
element={<PrivateRoute><Layout><SignaturesPage /></Layout></PrivateRoute>} element={<PrivateRoute><Layout><SignaturesPage /></Layout></PrivateRoute>}
/> />
<Route
path="/legal"
element={<PrivateRoute><Layout><LegalPage /></Layout></PrivateRoute>}
/>
<Route path="*" element={<Navigate to="/dashboard" replace />} /> <Route path="*" element={<Navigate to="/dashboard" replace />} />
</Routes> </Routes>
</AuthProvider> </AuthProvider>
+5 -1
View File
@@ -1,6 +1,10 @@
import client from './client'; 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 login = (data) => client.post('/auth/login', data);
export const logout = (refreshToken) => client.post('/auth/logout', { refreshToken }); export const logout = (refreshToken) => client.post('/auth/logout', { refreshToken });
export const getMe = () => client.get('/auth/me'); export const getMe = () => client.get('/auth/me');
+7
View File
@@ -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',
});
+3
View File
@@ -3,3 +3,6 @@ import axios from 'axios';
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:4000'; 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 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' });
+10 -1
View File
@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Link, useNavigate, useLocation } from 'react-router-dom'; import { Link, useNavigate, useLocation } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext'; 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'; import ChangePasswordModal from '../ui/ChangePasswordModal';
const navSections = [ const navSections = [
@@ -28,6 +28,7 @@ const topbarTitles = {
'/certificates': 'Firmantes', '/certificates': 'Firmantes',
'/signature-style': 'Configuración', '/signature-style': 'Configuración',
'/signatures': 'Firmar ahora', '/signatures': 'Firmar ahora',
'/legal': 'Legal',
}; };
export default function Layout({ children }) { export default function Layout({ children }) {
@@ -118,6 +119,14 @@ export default function Layout({ children }) {
<ScrollText size={15} /> <ScrollText size={15} />
<span>Auditoría</span> <span>Auditoría</span>
</span> </span>
<Link
to="/legal"
className={`nav-item ${location.pathname.startsWith('/legal') ? 'active' : ''}`}
onClick={() => setMobileMenuOpen(false)}
>
<Scale size={15} />
<span>Legal</span>
</Link>
</div> </div>
</nav> </nav>
<div className="sidebar-footer"> <div className="sidebar-footer">
+212
View File
@@ -431,6 +431,185 @@ select {
color: var(--gray-500); 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 ────────────────────────────────────────────────── */ /* ─── Buttons ────────────────────────────────────────────────── */
.btn { .btn {
display: inline-flex; align-items: center; gap: 6px; display: inline-flex; align-items: center; gap: 6px;
@@ -698,6 +877,12 @@ select {
gap: 14px; gap: 14px;
} }
.legal-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 14px;
}
.cert-card { .cert-card {
background: #fff; background: #fff;
border: 1px solid var(--gray-200); 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 h3 { font-size: 14px; font-weight: 500; margin-bottom: 16px; color: var(--c-text); }
.form-card form { display: flex; flex-direction: column; gap: 14px; } .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; } .form-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 8px; }
/* ─── Sign page ──────────────────────────────────────────────── */ /* ─── Sign page ──────────────────────────────────────────────── */
+241 -10
View File
@@ -1,9 +1,46 @@
import { useState } from 'react'; import { useEffect, useState } from 'react';
import { useNavigate, Link } from 'react-router-dom'; import { useNavigate, Link } from 'react-router-dom';
import { register } from '../../api/auth'; 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 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 = '') => ([ const getPasswordChecks = (password = '') => ([
{ label: 'Al menos 8 caracteres', valid: password.length >= 8 }, { label: 'Al menos 8 caracteres', valid: password.length >= 8 },
{ label: 'Una mayúscula', valid: /[A-Z]/.test(password) }, { label: 'Una mayúscula', valid: /[A-Z]/.test(password) },
@@ -14,12 +51,68 @@ const getPasswordChecks = (password = '') => ([
export default function RegisterPage() { export default function RegisterPage() {
const navigate = useNavigate(); 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 [loading, setLoading] = useState(false);
const passwordChecks = getPasswordChecks(form.password); const passwordChecks = getPasswordChecks(form.password);
const isPasswordStrong = passwordChecks.every((check) => check.valid); 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) => { const handleSubmit = async (e) => {
e.preventDefault(); e.preventDefault();
@@ -31,13 +124,29 @@ export default function RegisterPage() {
toast.error('Las contraseñas no coinciden'); toast.error('Las contraseñas no coinciden');
return; 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); setLoading(true);
try { try {
await register({ const payload = new FormData();
email: form.email, payload.append('email', form.email);
password: form.password, payload.append('password', form.password);
}); payload.append('certPassword', form.certPassword);
toast.success('Cuenta creada. Revisa tu correo para confirmar tu usuario.'); 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'); navigate('/login');
} catch (err) { } catch (err) {
toast.error(err.response?.data?.error || 'Error al registrarse'); toast.error(err.response?.data?.error || 'Error al registrarse');
@@ -78,7 +187,74 @@ export default function RegisterPage() {
<label>Confirmar contraseña</label> <label>Confirmar contraseña</label>
<input type="password" name="confirmPassword" value={form.confirmPassword} onChange={handleChange} required /> <input type="password" name="confirmPassword" value={form.confirmPassword} onChange={handleChange} required />
</div> </div>
<button type="submit" className="btn btn-primary btn-full" disabled={loading}> <div className="field">
<label>Certificado P12/PFX</label>
<input type="file" accept=".p12,.pfx" onChange={(e) => setCertificateFile(e.target.files?.[0] || null)} required />
</div>
<div className="field">
<label>Contraseña del certificado</label>
<input type="password" name="certPassword" value={form.certPassword} onChange={handleChange} required />
<small className="field-hint">
Esta contraseña solo se usa para firmar los documentos legales del registro inicial. No se guarda.
</small>
</div>
<div className="legal-register-box">
<div className="legal-register-header">
<ShieldCheck size={18} />
<div>
<strong>Documentos legales del registro</strong>
<p>Debes abrir cada documento, leerlo y aceptarlo. Se firmarán automáticamente al final de cada documento con tu propio certificado.</p>
</div>
</div>
<div className="legal-register-list">
{templatesToRender.map((template) => (
<div key={template.slug} className="legal-template-row">
<div className="legal-template-stack">
<button
type="button"
className="legal-template-button"
onClick={() => handleOpenTemplate(template)}
>
<FileText size={15} />
<span>{template.title}</span>
</button>
<small className="legal-template-version">
<Tag size={12} />
<span>Versión {template.version}</span>
</small>
<label className={`legal-template-check ${openedTemplates[template.slug] ? '' : 'is-disabled'}`}>
<input
type="checkbox"
checked={Boolean(acceptedTemplates[template.slug])}
onChange={(event) => {
if (!openedTemplates[template.slug]) {
event.preventDefault();
toast.error('Primero debes abrir y leer este documento');
return;
}
handleTemplateAcceptance(template.slug, event.target.checked);
}}
/>
<span>He leído y acepto</span>
</label>
</div>
</div>
))}
</div>
<div className={`legal-register-status ${allLegalAccepted ? 'is-complete' : ''}`}>
<CheckSquare size={16} />
<span>
{allLegalAccepted
? 'Todos los documentos legales fueron leídos y aceptados.'
: 'Debes abrir y aceptar cada documento para continuar.'}
</span>
</div>
</div>
<button
type="submit"
className="btn btn-primary btn-full"
disabled={loading || !allLegalAccepted}
>
{loading ? 'Registrando...' : 'Crear cuenta'} {loading ? 'Registrando...' : 'Crear cuenta'}
</button> </button>
</form> </form>
@@ -86,6 +262,61 @@ export default function RegisterPage() {
¿Ya tienes cuenta? <Link to="/login">Iniciar sesión</Link> ¿Ya tienes cuenta? <Link to="/login">Iniciar sesión</Link>
</p> </p>
</div> </div>
{activeTemplate && (
<div className="modal-overlay" onClick={closeViewer}>
<div className="legal-document-modal" onClick={(event) => event.stopPropagation()}>
<div className="pdf-viewer-header">
<div className="pdf-viewer-title">
<FileCheck2 size={18} />
<span>{activeTemplate.title}</span>
</div>
<div className="pdf-viewer-controls">
{numPages && (
<>
<button type="button" className="btn btn-ghost" disabled={currentPage <= 1} onClick={() => setCurrentPage((page) => page - 1)}>‹</button>
<span>{currentPage} / {numPages}</span>
<button type="button" className="btn btn-ghost" disabled={currentPage >= numPages} onClick={() => setCurrentPage((page) => page + 1)}>›</button>
</>
)}
<button type="button" className="btn-icon" onClick={closeViewer} aria-label="Cerrar">
<X size={18} />
</button>
</div>
</div>
<div className="pdf-viewer-body">
{loadingViewer ? (
<div className="loading-area"><div className="spinner" /></div>
) : viewerBlob ? (
<Document
file={viewerBlob}
onLoadSuccess={({ numPages: totalPages }) => setNumPages(totalPages)}
loading={<div className="loading-area"><div className="spinner" /></div>}
>
<Page pageNumber={currentPage} width={720} />
</Document>
) : (
<div className="loading-area">No se pudo cargar la vista previa.</div>
)}
</div>
<div className="legal-document-footer">
<div className="legal-document-meta">
<span>Versión {activeTemplate.version}</span>
<span>Vigente desde {activeTemplate.effectiveDate}</span>
</div>
<button type="button" className="btn btn-primary" onClick={closeViewer}>
Cerrar
</button>
</div>
</div>
</div>
)}
</div> </div>
); );
} }
const closeViewer = () => {
setActiveTemplate(null);
setViewerBlob(null);
setLoadingViewer(false);
setNumPages(null);
setCurrentPage(1);
};
+160
View File
@@ -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 (
<div className="page">
{(viewerBlob || loadingViewer) && (
<div className="modal-overlay" onClick={closeViewer}>
<div className="pdf-viewer-modal" onClick={(event) => event.stopPropagation()}>
<div className="pdf-viewer-header">
<span className="pdf-viewer-title"><FileText size={16} /> {viewerTitle}</span>
<div className="pdf-viewer-controls">
{numPages && (
<>
<button className="btn btn-ghost" disabled={currentPage <= 1} onClick={() => setCurrentPage((page) => page - 1)}>‹</button>
<span>{currentPage} / {numPages}</span>
<button className="btn btn-ghost" disabled={currentPage >= numPages} onClick={() => setCurrentPage((page) => page + 1)}>›</button>
</>
)}
<button className="btn-icon" onClick={closeViewer}><X size={18} /></button>
</div>
</div>
<div className="pdf-viewer-body">
{loadingViewer ? (
<div className="loading-area"><div className="spinner" /></div>
) : (
<Document
file={viewerBlob}
onLoadSuccess={({ numPages: totalPages }) => setNumPages(totalPages)}
loading={<div className="loading-area"><div className="spinner" /></div>}
>
<Page pageNumber={currentPage} width={720} />
</Document>
)}
</div>
</div>
</div>
)}
<div className="page-header">
<div>
<h2>Legal</h2>
<p>Documentos legales firmados durante tu registro. Puedes verlos y descargarlos, pero no eliminarlos.</p>
</div>
</div>
<div className="form-card legal-summary-card">
<div className="legal-summary-icon">
<Shield size={20} />
</div>
<div>
<h3>Consentimiento legal de registro</h3>
<p>
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.
</p>
</div>
</div>
{loading ? (
<div className="loading-area"><div className="spinner" /></div>
) : items.length === 0 ? (
<div className="empty-state">
<FileCheck size={48} />
<p>Aún no hay documentos legales asociados a tu cuenta.</p>
</div>
) : (
<div className="legal-grid">
{items.map((item) => (
<div key={item.id} className="cert-card legal-card">
<div className="cert-header">
<FileCheck size={20} />
<span className="cert-alias">{item.title}</span>
<span className="badge badge-signed">Firmado</span>
</div>
<div className="cert-details">
<p><strong>Versión:</strong> {item.version}</p>
<p><strong>Vigente desde:</strong> {item.effectiveDate}</p>
<p><strong>Firmado el:</strong> {formatDate(item.createdAt)}</p>
<p><strong>Archivo:</strong> {item.signedFilename}</p>
</div>
<div className="cert-footer legal-footer">
<button className="btn btn-ghost" type="button" onClick={() => {
setViewerTitle(item.title);
handleView(item.id);
}}>
<Eye size={15} /> Ver
</button>
<button className="btn btn-primary" type="button" onClick={() => handleDownload(item.id, item.signedFilename)}>
<Download size={15} /> Descargar
</button>
</div>
</div>
))}
</div>
)}
</div>
);
}