se agrega los requerimientos de la LODPD con firma de documentos
This commit is contained in:
@@ -16,8 +16,15 @@ interface LinkedSignApiService {
|
||||
@POST("auth/login")
|
||||
suspend fun login(@Body body: LoginRequest): ApiEnvelope<LoginData>
|
||||
|
||||
@Multipart
|
||||
@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")
|
||||
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 {
|
||||
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
|
||||
)
|
||||
|
||||
data class RegisterRequest(
|
||||
val email: String,
|
||||
val password: String
|
||||
)
|
||||
|
||||
data class RegisterData(
|
||||
val message: String?,
|
||||
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 {
|
||||
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<Uri?>(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),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 = {}) => {
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = `
|
||||
<div style="font-family: Arial, sans-serif; line-height: 1.5; color: #111827;">
|
||||
<h2 style="margin-bottom: 12px;">Confirma tu cuenta de LinkedSign</h2>
|
||||
@@ -24,6 +26,7 @@ const sendVerificationEmail = async ({ name, email, verificationToken }) => {
|
||||
</p>
|
||||
<p>Si el botón no funciona, copia y pega este enlace en tu navegador:</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>
|
||||
</div>
|
||||
`;
|
||||
@@ -33,6 +36,7 @@ const sendVerificationEmail = async ({ name, email, verificationToken }) => {
|
||||
subject,
|
||||
html,
|
||||
text,
|
||||
attachments,
|
||||
});
|
||||
|
||||
if (!env.mail.user || !env.mail.pass) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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={<PrivateRoute><Layout><SignaturesPage /></Layout></PrivateRoute>}
|
||||
/>
|
||||
<Route
|
||||
path="/legal"
|
||||
element={<PrivateRoute><Layout><LegalPage /></Layout></PrivateRoute>}
|
||||
/>
|
||||
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
||||
</Routes>
|
||||
</AuthProvider>
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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,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' });
|
||||
|
||||
@@ -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 }) {
|
||||
<ScrollText size={15} />
|
||||
<span>Auditoría</span>
|
||||
</span>
|
||||
<Link
|
||||
to="/legal"
|
||||
className={`nav-item ${location.pathname.startsWith('/legal') ? 'active' : ''}`}
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
>
|
||||
<Scale size={15} />
|
||||
<span>Legal</span>
|
||||
</Link>
|
||||
</div>
|
||||
</nav>
|
||||
<div className="sidebar-footer">
|
||||
|
||||
@@ -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 ──────────────────────────────────────────────── */
|
||||
|
||||
@@ -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() {
|
||||
<label>Confirmar contraseña</label>
|
||||
<input type="password" name="confirmPassword" value={form.confirmPassword} onChange={handleChange} required />
|
||||
</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'}
|
||||
</button>
|
||||
</form>
|
||||
@@ -86,6 +262,61 @@ export default function RegisterPage() {
|
||||
¿Ya tienes cuenta? <Link to="/login">Iniciar sesión</Link>
|
||||
</p>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
const closeViewer = () => {
|
||||
setActiveTemplate(null);
|
||||
setViewerBlob(null);
|
||||
setLoadingViewer(false);
|
||||
setNumPages(null);
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user