feat: version 4.6.0 -- Cambio de correo con aprobacion

El usuario solicita el cambio desde Mi cuenta (correo nuevo + clave actual). Lo
aprueba un admin/superadmin (colaboradores), solo un superadmin (admins) o el propio
superadmin mediante enlace a su correo actual. Tras aprobar se envia confirmacion al
correo nuevo y aviso al actual; al confirmar se aplica el cambio y se cierran todas
las sesiones. Todo el proceso queda en auditoria. Migracion 035.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Milton Gonzalez
2026-10-28 12:03:23 -05:00
parent d562351242
commit bdc62403f4
25 changed files with 449 additions and 6 deletions
+2
View File
@@ -107,6 +107,7 @@ Adaptación web (no es una app empaquetada, corre en pestaña de navegador):
## Seguridad (proyecto con auth/usuarios) ## Seguridad (proyecto con auth/usuarios)
- Permisos validados SIEMPRE en el backend, nunca solo en el frontend. - Permisos validados SIEMPRE en el backend, nunca solo en el frontend.
- Cambio de correo con aprobación (v4.6.0): reautenticación, aprobador según rol y confirmación en el correo nuevo; sesiones revocadas solo al aplicar (migración 035).
- Autorregistro con doble candado (decisión posterior al MVP inicial, ver `SPEC.md`): el usuario - Autorregistro con doble candado (decisión posterior al MVP inicial, ver `SPEC.md`): el usuario
se crea a sí mismo pero queda `pending_verification` → confirma su correo (token de un solo uso, se crea a sí mismo pero queda `pending_verification` → confirma su correo (token de un solo uso,
24h) → `pending_approval` → un `admin`/`superadmin` aprueba (elige rol/departamento) o rechaza 24h) → `pending_approval` → un `admin`/`superadmin` aprueba (elige rol/departamento) o rechaza
@@ -132,6 +133,7 @@ Adaptación web (no es una app empaquetada, corre en pestaña de navegador):
- Sin SMTP configurado, el sistema degrada a modo dev: los correos de verificación/recuperación se - Sin SMTP configurado, el sistema degrada a modo dev: los correos de verificación/recuperación se
loguean en la consola del backend en vez de enviarse (nunca rompe el flujo). loguean en la consola del backend en vez de enviarse (nunca rompe el flujo).
## Regla de UX para pantallas ## Regla de UX para pantallas
- No mezclar listado y formulario de creación en la vista inicial de un módulo. - No mezclar listado y formulario de creación en la vista inicial de un módulo.
+13
View File
@@ -279,3 +279,16 @@ se registra en consola de desarrollo. Antes de usar estas rutas se debe aplicar
- `403` sin permisos para la acción (según rol) - `403` sin permisos para la acción (según rol)
- `404` no encontrado (incluye cuando el recurso existe pero no pertenece al usuario/alcance) - `404` no encontrado (incluye cuando el recurso existe pero no pertenece al usuario/alcance)
- `500` error interno (sanitizado, sin detalle de stack en producción) - `500` error interno (sanitizado, sin detalle de stack en producción)
## Cambio de correo (v4.6.0)
- `GET /api/account/email-change`: última solicitud propia (requiere JWT).
- `POST /api/account/email-change`: `{ newEmail, currentPassword }`; crea una solicitud tras validar la clave, con límite de 20 solicitudes por 15 minutos.
- `DELETE /api/account/email-change`: cancela la solicitud propia pendiente o aprobada.
- `GET /api/users/email-change-requests`: pendientes que el admin/superadmin puede decidir.
- `POST /api/users/email-change-requests/:id/approve`: aprueba y envía el enlace de confirmación al correo nuevo.
- `POST /api/users/email-change-requests/:id/reject`: `{ reason }` obligatorio; avisa al correo actual.
- `POST /api/auth/email-change/self-approve`: `{ token }`; enlace público de aprobación exclusiva del superadmin, un solo uso.
- `POST /api/auth/email-change/confirm`: `{ token }`; enlace público de confirmación del correo nuevo. Revoca todas las sesiones al aplicar.
Los endpoints públicos tienen límite de 20 solicitudes por 15 minutos. Los enlaces duran 24 horas; las solicitudes abiertas vencen a los 7 días. Los tokens se almacenan solo como SHA-256.
+4
View File
@@ -240,3 +240,7 @@ en producción.
sistema (según `system_category` del agente + nombre, ver `normalizer.js#isComponent`); las vistas de sistema (según `system_category` del agente + nombre, ver `normalizer.js#isComponent`); las vistas de
software lo ocultan salvo que se marque la casilla. `inventory_tokens` guarda el hash (`token_hash`, valida la ingesta) y el token cifrado software lo ocultan salvo que se marque la casilla. `inventory_tokens` guarda el hash (`token_hash`, valida la ingesta) y el token cifrado
(`token_enc`, migración `025`, AES-256-GCM con `ENCRYPTION_KEY`) para regenerar el instalador. (`token_enc`, migración `025`, AES-256-GCM con `ENCRYPTION_KEY`) para regenerar el instalador.
## Cambio de correo (migración 035, no aplicada)
`email_change_requests` guarda usuario, correo anterior/nuevo, estado, decisión, motivo y fechas. Los tokens de aprobación y confirmación se guardan solo como hash SHA-256. Índices únicos sobre columnas generadas impiden más de una solicitud abierta por usuario o correo nuevo; el cotejamiento `utf8mb4_unicode_ci` evita duplicados por mayúsculas. `notifications.entity_type` agrega `email_change_request` y conserva `project`, `activity` y `meeting`. El migrador reejecuta todos los archivos; la tabla se crea con `IF NOT EXISTS`.
+4
View File
@@ -1,5 +1,9 @@
# Changelog - project.linkedcorp.net # Changelog - project.linkedcorp.net
## [4.6.0] - 2026-10-28
### Added
- Cambio de correo con aprobación por rol, confirmación de correo nuevo, auditoría y revocación de todas las sesiones al aplicar. Migración 035 pendiente de aplicar.
## [4.5.0] - 2026-10-28 ## [4.5.0] - 2026-10-28
### Added ### Added
- Calendario: capa de tareas recurrentes con sus próximos vencimientos (el pendiente real y las - Calendario: capa de tareas recurrentes con sus próximos vencimientos (el pendiente real y las
+11 -1
View File
@@ -1,9 +1,19 @@
# ESTADO — project.linkedcorp.net # ESTADO — project.linkedcorp.net
Ultima actualizacion: 2026-10-28 (v4.5.0 en Git, sin desplegar) Ultima actualizacion: 2026-10-28 (v4.6.0 en archivos, sin commit ni desplegar)
Fase actual: **En produccion** (`https://project.linkedcorp.net`, v1.1.0). MVP fase 1 operativo + Fase actual: **En produccion** (`https://project.linkedcorp.net`, v1.1.0). MVP fase 1 operativo +
autorregistro/aprobacion/SMTP. autorregistro/aprobacion/SMTP.
## Cambio de correo con aprobación (v4.6.0, sin desplegar)
- Implementado en Mi cuenta y Organización → Usuarios, con enlaces públicos de aprobación propia del superadmin y confirmación del correo nuevo.
- Migración `035_email_change_requests.sql` **aplicada en dev** (2026-10-28), no en producción (la aplica
el actualizador). Probado en dev con usuarios temporales (ya borrados): colaborador→admin aprueba,
admin→solo superadmin, superadmin→se autoaprueba por enlace al correo actual; sesiones siguen vivas
hasta confirmar desde el correo nuevo y ahí se cierran todas; login viejo falla y nuevo funciona;
tokens de un solo uso y nunca en auditoría. Pendiente: envío real con SMTP (dev → log).
- No se hizo commit ni push.
## Calendario de reuniones (v4.4.0, sin desplegar) ## Calendario de reuniones (v4.4.0, sin desplegar)
- v4.5.0: calendario personal para todos los roles (ver SPEC §7), capas Proyectos/Actividades/ - v4.5.0: calendario personal para todos los roles (ver SPEC §7), capas Proyectos/Actividades/
+3
View File
@@ -25,6 +25,9 @@ tiene cada cosa — sin que cargar la información sea tedioso para quien lo usa
## Modulos (MVP) ## Modulos (MVP)
### 1. Seguridad ### 1. Seguridad
- Cambio de correo (v4.6.0): desde Mi cuenta, correo nuevo y clave actual. Admin/superadmin aprueban solicitudes de usuarios comunes; solo superadmin aprueba admins; un superadmin se autoaprueba mediante enlace enviado a su correo actual. El nuevo correo se confirma por enlace de un solo uso. Solo entonces se actualiza `users.email` y se cierran todas las sesiones; solicitudes abiertas vencen a los 7 días y enlaces a las 24 horas. El usuario puede cancelar hasta aplicar.
- Login/logout con JWT (`sub`, `sv`, `sid`) y fila activa por sesión en `user_sessions`. - Login/logout con JWT (`sub`, `sv`, `sid`) y fila activa por sesión en `user_sessions`.
Mi cuenta muestra perfil, preferencias, seguridad y sesiones activas. Cada usuario puede cerrar Mi cuenta muestra perfil, preferencias, seguridad y sesiones activas. Cada usuario puede cerrar
una sesión propia o todas las demás; admin/superadmin pueden cerrar sesiones de usuarios desde una sesión propia o todas las demás; admin/superadmin pueden cerrar sesiones de usuarios desde
+1 -1
View File
@@ -1 +1 @@
4.5.0 4.6.0
@@ -0,0 +1,28 @@
CREATE TABLE IF NOT EXISTS email_change_requests (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
old_email VARCHAR(160) NOT NULL,
new_email VARCHAR(160) NOT NULL,
status ENUM('pending','approved','rejected','cancelled','expired','applied','failed') NOT NULL DEFAULT 'pending',
requires_self_approval TINYINT(1) NOT NULL DEFAULT 0,
approve_token_hash CHAR(64) NULL,
approve_token_expires DATETIME NULL,
confirm_token_hash CHAR(64) NULL,
confirm_token_expires DATETIME NULL,
decided_by INT NULL,
decided_at DATETIME NULL,
reject_reason VARCHAR(1000) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
applied_at DATETIME NULL,
open_user_id INT AS (CASE WHEN status IN ('pending','approved') THEN user_id ELSE NULL END) PERSISTENT,
open_new_email VARCHAR(160) AS (CASE WHEN status IN ('pending','approved') THEN new_email ELSE NULL END) PERSISTENT,
UNIQUE KEY uq_email_change_open_user (open_user_id),
UNIQUE KEY uq_email_change_open_email (open_new_email),
INDEX idx_email_change_user_status (user_id, status),
INDEX idx_email_change_approve_hash (approve_token_hash),
INDEX idx_email_change_confirm_hash (confirm_token_hash),
CONSTRAINT fk_email_change_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
CONSTRAINT fk_email_change_decider FOREIGN KEY (decided_by) REFERENCES users(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
ALTER TABLE notifications MODIFY COLUMN entity_type ENUM('project','activity','meeting','email_change_request') NULL;
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "project-linkedcorp-backend", "name": "project-linkedcorp-backend",
"version": "4.5.0", "version": "4.6.0",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/server.js", "main": "src/server.js",
+4
View File
@@ -5,6 +5,7 @@ import { env } from './config/env.js';
import { notFoundHandler, errorHandler } from './middleware/errorHandler.js'; import { notFoundHandler, errorHandler } from './middleware/errorHandler.js';
import authRoutes from './modules/auth/routes.js'; import authRoutes from './modules/auth/routes.js';
import { accountEmailRoutes, usersEmailRoutes, publicEmailRoutes } from './modules/email-change/routes.js';
import usersRoutes from './modules/users/routes.js'; import usersRoutes from './modules/users/routes.js';
import departmentsRoutes from './modules/departments/routes.js'; import departmentsRoutes from './modules/departments/routes.js';
import catalogsRoutes from './modules/catalogs/routes.js'; import catalogsRoutes from './modules/catalogs/routes.js';
@@ -48,6 +49,9 @@ app.use(express.json({ limit: '2mb' }));
app.get('/health', (req, res) => res.json({ success: true, data: { status: 'ok' }, error: null })); app.get('/health', (req, res) => res.json({ success: true, data: { status: 'ok' }, error: null }));
app.use('/api/auth/email-change', publicEmailRoutes);
app.use('/api/account/email-change', accountEmailRoutes);
app.use('/api/users/email-change-requests', usersEmailRoutes);
app.use('/api/auth', authRoutes); app.use('/api/auth', authRoutes);
app.use('/api/users', usersRoutes); app.use('/api/users', usersRoutes);
app.use('/api/departments', departmentsRoutes); app.use('/api/departments', departmentsRoutes);
@@ -0,0 +1,21 @@
import { Router } from 'express';
import rateLimit from 'express-rate-limit';
import { requireAuth } from '../../middleware/auth.js';
import { requireModule } from '../../middleware/modules.js';
import { ok, asyncHandler } from '../../utils/http.js';
import * as service from './service.js';
export const accountEmailRoutes = Router();
export const usersEmailRoutes = Router();
export const publicEmailRoutes = Router();
const limiter = rateLimit({ windowMs: 15 * 60 * 1000, limit: 20, standardHeaders: true, legacyHeaders: false });
accountEmailRoutes.use(requireAuth);
accountEmailRoutes.get('/', asyncHandler(async (req, res) => ok(res, await service.mine(req.currentUser.id))));
accountEmailRoutes.post('/', limiter, asyncHandler(async (req, res) => ok(res, await service.request(req.currentUser.id, req.body), 201)));
accountEmailRoutes.delete('/', asyncHandler(async (req, res) => ok(res, await service.cancel(req.currentUser.id))));
usersEmailRoutes.use(requireAuth, requireModule('organization'));
usersEmailRoutes.get('/', asyncHandler(async (req, res) => ok(res, await service.list(req.currentUser))));
usersEmailRoutes.post('/:id/approve', asyncHandler(async (req, res) => ok(res, await service.decide(Number(req.params.id), req.currentUser, 'approved'))));
usersEmailRoutes.post('/:id/reject', asyncHandler(async (req, res) => ok(res, await service.decide(Number(req.params.id), req.currentUser, 'rejected', req.body?.reason))));
publicEmailRoutes.post('/self-approve', limiter, asyncHandler(async (req, res) => ok(res, await service.selfApprove(req.body?.token))));
publicEmailRoutes.post('/confirm', limiter, asyncHandler(async (req, res) => ok(res, await service.confirm(req.body?.token))));
+210
View File
@@ -0,0 +1,210 @@
import crypto from 'node:crypto';
import bcrypt from 'bcryptjs';
import { pool } from '../../config/db.js';
import { env } from '../../config/env.js';
import { HttpError } from '../../utils/http.js';
import { logAudit } from '../../utils/audit.js';
import { sendMail } from '../../utils/mailer.js';
import { emailChangeLinkEmail, emailChangeNoticeEmail } from '../../utils/emailHtml.js';
const hash = (token) => crypto.createHash('sha256').update(token).digest('hex');
const token = () => crypto.randomBytes(32).toString('hex');
const detail = (r, extra = {}) => ({ request_id: r.id, old_email: r.old_email, new_email: r.new_email, ...extra });
const publicRow = (r) => r && ({ id: r.id, userId: r.user_id, name: r.name, oldEmail: r.old_email, newEmail: r.new_email, status: r.status, rejectReason: r.reject_reason, createdAt: r.created_at });
const validToken = (value) => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value);
async function audit(connection, actor, action, r, extra) {
await connection.query('INSERT INTO audit_log (user_id, action, entity_type, entity_id, detail_json) VALUES (?, ?, ?, ?, ?)',
[actor, action, 'email_change_request', r.id, JSON.stringify(detail(r, extra))]);
}
async function expire() {
const [rows] = await pool.query(`SELECT * FROM email_change_requests WHERE (status = 'pending' AND
(created_at < DATE_SUB(NOW(), INTERVAL 7 DAY) OR (requires_self_approval = 1 AND approve_token_expires <= NOW())))
OR (status = 'approved' AND (created_at < DATE_SUB(NOW(), INTERVAL 7 DAY) OR confirm_token_expires <= NOW()))`);
for (const r of rows) {
const [result] = await pool.query(`UPDATE email_change_requests SET status = 'expired' WHERE id = ? AND status = ?`, [r.id, r.status]);
if (result.affectedRows) await logAudit({ userId: r.user_id, action: 'email_change_expired', entityType: 'email_change_request', entityId: r.id, detail: detail(r) });
}
}
// Se llama despues del commit: un fallo aqui (correo o auditoria) nunca debe tumbar la respuesta.
async function mail(r, to, kind, url, reason = '') {
try {
const html = url ? emailChangeLinkEmail(r.name, url, kind) : emailChangeNoticeEmail(r.name, r.new_email, kind, reason);
const result = await sendMail({ to, subject: kind === 'rejected' ? 'Solicitud de cambio de correo rechazada' : 'Cambio de correo — project.linkedcorp.net', html });
await logAudit({ userId: r.user_id, action: 'email_change_mail_sent', entityType: 'email_change_request', entityId: r.id,
detail: detail(r, { destination: to === r.old_email ? 'actual' : 'nuevo', purpose: kind, delivered: result.sent, dev: result.dev }) });
} catch (err) {
console.error('[email-change] correo:', err.message);
}
}
export async function request(userId, payload) {
await expire();
const newEmail = String(payload?.newEmail || '').trim().toLowerCase();
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(newEmail) || newEmail.length > 160) throw new HttpError(400, 'Correo invalido');
if (!payload?.currentPassword) throw new HttpError(400, 'Clave actual obligatoria');
const [users] = await pool.query(`SELECT u.id, u.name, u.email, u.password_hash, r.code role_code FROM users u
JOIN roles r ON r.id = u.role_id WHERE u.id = ?`, [userId]);
const user = users[0];
if (!user || !(await bcrypt.compare(payload.currentPassword, user.password_hash))) throw new HttpError(400, 'Clave actual incorrecta');
if (user.email.toLowerCase() === newEmail) throw new HttpError(400, 'El correo nuevo debe ser diferente');
const [busy] = await pool.query(`SELECT 1 FROM users WHERE LOWER(email) = ? UNION ALL
SELECT 1 FROM email_change_requests WHERE open_new_email = ? LIMIT 1`, [newEmail, newEmail]);
if (busy.length) throw new HttpError(400, 'Ese correo no esta disponible');
const self = user.role_code === 'superadmin';
const approvalToken = self ? token() : null;
let id;
try {
const [result] = await pool.query(`INSERT INTO email_change_requests
(user_id, old_email, new_email, requires_self_approval, approve_token_hash, approve_token_expires)
VALUES (?, ?, ?, ?, ?, IF(?, DATE_ADD(NOW(), INTERVAL 24 HOUR), NULL))`,
[userId, user.email, newEmail, self ? 1 : 0, approvalToken ? hash(approvalToken) : null, self ? 1 : 0]);
id = result.insertId;
} catch (err) {
if (err.code === 'ER_DUP_ENTRY') throw new HttpError(400, 'Ya existe una solicitud abierta o ese correo no esta disponible');
throw err;
}
const r = { id, user_id: userId, name: user.name, old_email: user.email, new_email: newEmail };
await logAudit({ userId, action: 'email_change_request', entityType: 'email_change_request', entityId: id, detail: detail(r) });
if (self) await mail(r, user.email, 'approve', `${env.corsOrigin}/aprobar-cambio-correo?token=${approvalToken}`);
else {
const roles = user.role_code === 'admin' ? ['superadmin'] : ['admin', 'superadmin'];
const [approvers] = await pool.query(`SELECT u.id FROM users u JOIN roles role ON role.id = u.role_id
WHERE role.code IN (?) AND u.is_active = 1 AND u.id <> ?`, [roles, userId]);
for (const approver of approvers) await pool.query(`INSERT INTO notifications (user_id, type, message, entity_type, entity_id)
VALUES (?, 'email_change_pending', ?, 'email_change_request', ?)`, [approver.id, `Cambio de correo pendiente: ${user.name}`, id]);
}
return { id, status: 'pending' };
}
export async function mine(userId) {
await expire();
const [rows] = await pool.query(`SELECT * FROM email_change_requests WHERE user_id = ? ORDER BY id DESC LIMIT 1`, [userId]);
return publicRow(rows[0]);
}
export async function cancel(userId) {
await expire();
const connection = await pool.getConnection();
try {
await connection.beginTransaction();
const [rows] = await connection.query(`SELECT * FROM email_change_requests WHERE user_id = ? AND status IN ('pending','approved') FOR UPDATE`, [userId]);
const r = rows[0];
if (!r) throw new HttpError(400, 'No hay solicitud abierta');
await connection.query(`UPDATE email_change_requests SET status = 'cancelled', approve_token_hash = NULL, confirm_token_hash = NULL WHERE id = ?`, [r.id]);
await audit(connection, userId, 'email_change_cancel', r);
await connection.commit();
return { cancelled: true };
} catch (err) { await connection.rollback(); throw err; } finally { connection.release(); }
}
function canDecide(r, actor) {
if (r.requires_self_approval || actor.id === r.user_id || r.role_code === 'superadmin' ||
(r.role_code === 'admin' && actor.roleCode !== 'superadmin') || !['admin','superadmin'].includes(actor.roleCode))
throw new HttpError(403, 'No puedes decidir esta solicitud');
}
export async function list(actor) {
await expire();
if (!['admin','superadmin'].includes(actor.roleCode)) throw new HttpError(403, 'Acceso denegado');
const [rows] = await pool.query(`SELECT e.*, u.name, role.code role_code FROM email_change_requests e
JOIN users u ON u.id = e.user_id JOIN roles role ON role.id = u.role_id
WHERE e.status = 'pending' AND e.requires_self_approval = 0 AND e.user_id <> ?
AND role.code <> 'superadmin' AND (role.code <> 'admin' OR ? = 'superadmin') ORDER BY e.created_at`, [actor.id, actor.roleCode]);
return rows.map(publicRow);
}
export async function decide(id, actor, decision, reason) {
await expire();
if (!Number.isSafeInteger(id) || id < 1) throw new HttpError(404, 'Solicitud no encontrada');
if (decision === 'rejected' && (!String(reason || '').trim() || String(reason).length > 1000)) throw new HttpError(400, 'Motivo obligatorio (maximo 1000 caracteres)');
const connection = await pool.getConnection();
let r, confirmToken;
try {
await connection.beginTransaction();
const [rows] = await connection.query(`SELECT e.*, u.name, role.code role_code FROM email_change_requests e JOIN users u ON u.id=e.user_id
JOIN roles role ON role.id=u.role_id WHERE e.id=? FOR UPDATE`, [id]);
r = rows[0];
if (!r) throw new HttpError(404, 'Solicitud no encontrada');
canDecide(r, actor);
if (r.status !== 'pending') throw new HttpError(400, 'Solicitud no pendiente');
confirmToken = decision === 'approved' ? token() : null;
await connection.query(`UPDATE email_change_requests SET status=?, decided_by=?, decided_at=NOW(), reject_reason=?,
confirm_token_hash=?, confirm_token_expires=IF(?, DATE_ADD(NOW(), INTERVAL 24 HOUR), NULL), approve_token_hash=NULL WHERE id=?`,
[decision, actor.id, decision === 'rejected' ? reason.trim() : null, confirmToken ? hash(confirmToken) : null, confirmToken ? 1 : 0, id]);
await audit(connection, actor.id, decision === 'approved' ? 'email_change_approve' : 'email_change_reject', r, decision === 'rejected' ? { reason: reason.trim() } : {});
await connection.query(`INSERT INTO notifications (user_id, type, message, entity_type, entity_id) VALUES (?, ?, ?, 'email_change_request', ?)`,
[r.user_id, `email_change_${decision}`, decision === 'approved' ? 'Tu cambio de correo fue aprobado; revisa el correo nuevo' : 'Tu cambio de correo fue rechazado', id]);
await connection.commit();
} catch (err) { await connection.rollback(); throw err; } finally { connection.release(); }
if (confirmToken) await mail(r, r.new_email, 'confirm', `${env.corsOrigin}/confirmar-cambio-correo?token=${confirmToken}`);
await mail(r, r.old_email, decision === 'approved' ? 'approved' : 'rejected', null, reason);
return { status: decision };
}
export async function selfApprove(value) {
if (!validToken(value)) throw new HttpError(400, 'Enlace invalido o vencido');
await expire();
const connection = await pool.getConnection();
let r, confirmToken;
try {
await connection.beginTransaction();
const [rows] = await connection.query(`SELECT e.*, (e.approve_token_expires <= NOW()) AS token_expired, u.name, role.code role_code FROM email_change_requests e JOIN users u ON u.id=e.user_id
JOIN roles role ON role.id=u.role_id WHERE e.approve_token_hash=? FOR UPDATE`, [hash(value)]);
r = rows[0];
if (r?.status === 'expired') throw new HttpError(400, 'El enlace vencio; la solicitud quedo vencida');
if (!r || r.status !== 'pending' || !r.requires_self_approval || r.role_code !== 'superadmin' || r.token_expired) throw new HttpError(400, 'Enlace invalido o vencido');
confirmToken = token();
await connection.query(`UPDATE email_change_requests SET status='approved', decided_by=?, decided_at=NOW(), approve_token_hash=NULL,
confirm_token_hash=?, confirm_token_expires=DATE_ADD(NOW(), INTERVAL 24 HOUR) WHERE id=?`, [r.user_id, hash(confirmToken), r.id]);
await audit(connection, r.user_id, 'email_change_self_approve', r);
await connection.commit();
} catch (err) { await connection.rollback(); throw err; } finally { connection.release(); }
await mail(r, r.new_email, 'confirm', `${env.corsOrigin}/confirmar-cambio-correo?token=${confirmToken}`);
await mail(r, r.old_email, 'approved');
return { status: 'approved' };
}
export async function confirm(value) {
if (!validToken(value)) throw new HttpError(400, 'Enlace invalido o vencido');
await expire();
const connection = await pool.getConnection();
let r, failure;
try {
await connection.beginTransaction();
const [rows] = await connection.query(`SELECT *, (confirm_token_expires <= NOW()) AS token_expired, (created_at < DATE_SUB(NOW(), INTERVAL 7 DAY)) AS request_expired FROM email_change_requests WHERE confirm_token_hash=? FOR UPDATE`, [hash(value)]);
r = rows[0];
if (!r) throw new HttpError(400, 'Enlace invalido');
if (r.status === 'expired') throw new HttpError(400, 'El enlace vencio; la solicitud quedo vencida');
if (r.status !== 'approved') throw new HttpError(400, 'Enlace invalido o ya utilizado');
const [occupied] = await connection.query(`SELECT id FROM users WHERE LOWER(email)=LOWER(?) AND id<>? LIMIT 1`, [r.new_email, r.user_id]);
failure = r.token_expired || r.request_expired ? 'expired' : occupied.length ? 'failed' : null;
if (failure) {
await connection.query(`UPDATE email_change_requests SET status=?, confirm_token_hash=NULL WHERE id=?`, [failure, r.id]);
await audit(connection, r.user_id, `email_change_${failure}`, r);
} else {
try {
await connection.query(`UPDATE users SET email=?, verification_token=NULL, verification_token_expires=NULL,
reset_token=NULL, reset_token_expires=NULL, session_version=session_version+1, edit_version=edit_version+1 WHERE id=?`, [r.new_email, r.user_id]);
} catch (err) {
if (err.code !== 'ER_DUP_ENTRY') throw err;
failure = 'failed';
await connection.query(`UPDATE email_change_requests SET status='failed', confirm_token_hash=NULL WHERE id=?`, [r.id]);
await audit(connection, r.user_id, 'email_change_failed', r);
}
if (!failure) {
await audit(connection, r.user_id, 'email_change_confirm', r);
const [revoked] = await connection.query(`UPDATE user_sessions SET revoked_at=NOW(), revoked_by=?, revoke_reason='email_change'
WHERE user_id=? AND revoked_at IS NULL`, [r.user_id, r.user_id]);
await audit(connection, r.user_id, 'email_change_sessions_revoked', r, { count: revoked.affectedRows });
await connection.query(`UPDATE email_change_requests SET status='applied', applied_at=NOW(), confirm_token_hash=NULL WHERE id=?`, [r.id]);
await audit(connection, r.user_id, 'email_change_applied', r);
}
}
await connection.commit();
} catch (err) { await connection.rollback(); throw err; } finally { connection.release(); }
if (failure) throw new HttpError(400, failure === 'expired' ? 'El enlace vencio; la solicitud quedo vencida' : 'El correo ya fue tomado; la solicitud quedo fallida');
return { email: r.new_email, sessionsClosed: true };
}
+10
View File
@@ -18,3 +18,13 @@ export function recoveryEmail(name, url) {
const safeUrl = escapeHtml(url); const safeUrl = escapeHtml(url);
return `<p>Hola ${escapeHtml(name)},</p><p>Para elegir una nueva clave entra a este enlace (vence en 1 hora):</p><p><a href="${safeUrl}">${safeUrl}</a></p><p>Si no pediste esto, ignora el correo.</p>`; return `<p>Hola ${escapeHtml(name)},</p><p>Para elegir una nueva clave entra a este enlace (vence en 1 hora):</p><p><a href="${safeUrl}">${safeUrl}</a></p><p>Si no pediste esto, ignora el correo.</p>`;
} }
export function emailChangeLinkEmail(name, url, purpose) {
const action = purpose === 'approve' ? 'aprobar la solicitud desde tu correo actual' : 'confirmar tu correo nuevo';
return `<p>Hola ${escapeHtml(name)},</p><p>Para ${action}, abre este enlace:</p><p><a href="${escapeHtml(url)}">${escapeHtml(url)}</a></p><p>Vence en 24 horas. Si no solicitaste el cambio, contacta al administrador.</p>`;
}
export function emailChangeNoticeEmail(name, newEmail, kind, reason = '') {
const message = kind === 'rejected' ? `se rechazó tu solicitud de cambio de correo. Motivo: ${escapeHtml(reason)}` : `se aprobó el cambio de tu correo a ${escapeHtml(newEmail)}`;
return `<p>Hola ${escapeHtml(name)},</p><p>${message}.</p><p>Si no fuiste tú, contacta al administrador.</p>`;
}
+1
View File
@@ -48,6 +48,7 @@ abierta `?activity=&activityTab=`, Organización `?tab=`, Equipos `?tab=&device=
|---|---| |---|---|
| Contraseñas (todo el módulo, incluido PIN y grupos) | Los datos están protegidos por PIN y cifrados; guardarlos en el navegador saltaría esa protección. | | Contraseñas (todo el módulo, incluido PIN y grupos) | Los datos están protegidos por PIN y cifrados; guardarlos en el navegador saltaría esa protección. |
| Cambiar contraseña (menú del usuario) | Solo contiene secretos. | | Cambiar contraseña (menú del usuario) | Solo contiene secretos. |
| Cambio de correo (Mi cuenta) | Solo se guarda el correo nuevo; la clave actual nunca se persiste. |
| Login, registro, olvidé/restablecer clave, aceptar invitación, restablecer PIN | Pantallas públicas: no hay usuario autenticado para aislar el borrador y contienen claves/tokens. | | Login, registro, olvidé/restablecer clave, aceptar invitación, restablecer PIN | Pantallas públicas: no hay usuario autenticado para aislar el borrador y contienen claves/tokens. |
| Documentos (subir archivo), logo y nombre de Empresa | Archivos y texto libre no se guardan en el navegador. | | Documentos (subir archivo), logo y nombre de Empresa | Archivos y texto libre no se guardan en el navegador. |
| Equipos → Agentes (token de instalación) | Token secreto; generar uno es una acción de un paso. | | Equipos → Agentes (token de instalación) | Token secreto; generar uno es una acción de un paso. |
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "project-linkedcorp-frontend", "name": "project-linkedcorp-frontend",
"private": true, "private": true,
"version": "4.5.0", "version": "4.6.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+3
View File
@@ -10,6 +10,7 @@ import AppShell from './AppShell.jsx';
import LoginPage from '../pages/Login/LoginPage.jsx'; import LoginPage from '../pages/Login/LoginPage.jsx';
import RegisterPage from '../pages/Login/RegisterPage.jsx'; import RegisterPage from '../pages/Login/RegisterPage.jsx';
import VerifyEmailPage from '../pages/Login/VerifyEmailPage.jsx'; import VerifyEmailPage from '../pages/Login/VerifyEmailPage.jsx';
import EmailChangeLinkPage from '../pages/Login/EmailChangeLinkPage.jsx';
import ForgotPasswordPage from '../pages/Login/ForgotPasswordPage.jsx'; import ForgotPasswordPage from '../pages/Login/ForgotPasswordPage.jsx';
import ResetPasswordPage from '../pages/Login/ResetPasswordPage.jsx'; import ResetPasswordPage from '../pages/Login/ResetPasswordPage.jsx';
import AcceptInvitePage from '../pages/Login/AcceptInvitePage.jsx'; import AcceptInvitePage from '../pages/Login/AcceptInvitePage.jsx';
@@ -33,6 +34,8 @@ const router = createBrowserRouter(createRoutesFromElements(
<Route path="/login" element={<LoginPage />} /> <Route path="/login" element={<LoginPage />} />
<Route path="/registro" element={<RegisterPage />} /> <Route path="/registro" element={<RegisterPage />} />
<Route path="/verificar-correo" element={<VerifyEmailPage />} /> <Route path="/verificar-correo" element={<VerifyEmailPage />} />
<Route path="/aprobar-cambio-correo" element={<EmailChangeLinkPage kind="approve" />} />
<Route path="/confirmar-cambio-correo" element={<EmailChangeLinkPage kind="confirm" />} />
<Route path="/olvide-clave" element={<ForgotPasswordPage />} /> <Route path="/olvide-clave" element={<ForgotPasswordPage />} />
<Route path="/restablecer-clave" element={<ResetPasswordPage />} /> <Route path="/restablecer-clave" element={<ResetPasswordPage />} />
<Route path="/restablecer-pin" element={<ResetVaultPinPage />} /> <Route path="/restablecer-pin" element={<ResetVaultPinPage />} />
+2
View File
@@ -1,5 +1,6 @@
export const messages = { export const messages = {
'es-EC': { 'es-EC': {
email_change_title: 'Cambiar correo', email_change_new: 'Correo nuevo', email_change_request: 'Solicitar cambio', email_change_requested: 'Solicitud creada', email_change_cancel: 'Cancelar solicitud', email_change_cancelled: 'Solicitud cancelada', email_change_requests: 'Solicitudes de cambio de correo', email_change_none: 'No hay solicitudes pendientes', email_change_approve: 'Aprobar', email_change_reject: 'Rechazar', email_change_reason: 'Motivo del rechazo', email_change_approved: 'Cambio aprobado', email_change_rejected: 'Cambio rechazado', email_change_approve_title: 'Aprobar mi cambio de correo', email_change_confirm_title: 'Confirmar correo nuevo', email_change_accept: 'Aceptar', email_change_self_approved: 'Aprobado. Revisa el correo nuevo para confirmarlo.', email_change_applied: 'Correo actualizado. Todas tus sesiones se cerraron. Inicia sesión con:', email_change_status_pending: 'Pendiente de aprobación', email_change_status_approved: 'Aprobada: revisa tu correo nuevo', email_change_status_rejected: 'Rechazada', email_change_status_cancelled: 'Cancelada', email_change_status_expired: 'Vencida', email_change_status_failed: 'Fallida', email_change_status_applied: 'Aplicada',
nav_calendar: 'Calendario', nav_calendar: 'Calendario',
calendar_date: 'Fecha', calendar_date: 'Fecha',
notification_meeting_created: 'Nueva reunión', notification_meeting_updated: 'Reunión modificada', notification_meeting_cancelled: 'Reunión cancelada', notification_meeting_occurrence_cancelled: 'Ocurrencia cancelada', notification_meeting_reminder: 'Recordatorio de reunión', notification_meeting_rsvp_accepted: 'Aceptó la reunión', notification_meeting_rsvp_declined: 'Rechazó la reunión', notification_meeting_rsvp_tentative: 'Respondió quizás', notification_meeting_created: 'Nueva reunión', notification_meeting_updated: 'Reunión modificada', notification_meeting_cancelled: 'Reunión cancelada', notification_meeting_occurrence_cancelled: 'Ocurrencia cancelada', notification_meeting_reminder: 'Recordatorio de reunión', notification_meeting_rsvp_accepted: 'Aceptó la reunión', notification_meeting_rsvp_declined: 'Rechazó la reunión', notification_meeting_rsvp_tentative: 'Respondió quizás',
@@ -625,6 +626,7 @@ export const messages = {
notifications_mark_all_read: 'Marcar todas como leidas', notifications_mark_all_read: 'Marcar todas como leidas',
}, },
'en-US': { 'en-US': {
email_change_title: 'Change email', email_change_new: 'New email', email_change_request: 'Request change', email_change_requested: 'Request created', email_change_cancel: 'Cancel request', email_change_cancelled: 'Request cancelled', email_change_requests: 'Email change requests', email_change_none: 'No pending requests', email_change_approve: 'Approve', email_change_reject: 'Reject', email_change_reason: 'Rejection reason', email_change_approved: 'Change approved', email_change_rejected: 'Change rejected', email_change_approve_title: 'Approve my email change', email_change_confirm_title: 'Confirm new email', email_change_accept: 'Accept', email_change_self_approved: 'Approved. Check your new email to confirm it.', email_change_applied: 'Email updated. All sessions were closed. Sign in with:', email_change_status_pending: 'Pending approval', email_change_status_approved: 'Approved: check your new email', email_change_status_rejected: 'Rejected', email_change_status_cancelled: 'Cancelled', email_change_status_expired: 'Expired', email_change_status_failed: 'Failed', email_change_status_applied: 'Applied',
nav_calendar: 'Calendar', nav_calendar: 'Calendar',
calendar_date: 'Date', calendar_date: 'Date',
notification_meeting_created: 'New meeting', notification_meeting_updated: 'Meeting updated', notification_meeting_cancelled: 'Meeting cancelled', notification_meeting_occurrence_cancelled: 'Occurrence cancelled', notification_meeting_reminder: 'Meeting reminder', notification_meeting_rsvp_accepted: 'Accepted meeting', notification_meeting_rsvp_declined: 'Declined meeting', notification_meeting_rsvp_tentative: 'Replied maybe', notification_meeting_created: 'New meeting', notification_meeting_updated: 'Meeting updated', notification_meeting_cancelled: 'Meeting cancelled', notification_meeting_occurrence_cancelled: 'Occurrence cancelled', notification_meeting_reminder: 'Meeting reminder', notification_meeting_rsvp_accepted: 'Accepted meeting', notification_meeting_rsvp_declined: 'Declined meeting', notification_meeting_rsvp_tentative: 'Replied maybe',
@@ -0,0 +1,29 @@
import { useState } from 'react';
import { Link, useSearchParams } from 'react-router-dom';
import { useUi } from '../../context/ui-context.jsx';
import { Button } from '../../components/Button.jsx';
import { selfApproveEmailChange, confirmEmailChange } from '../../services/emailChange.js';
export default function EmailChangeLinkPage({ kind }) {
const { t } = useUi();
const [params] = useSearchParams();
const [state, setState] = useState('ready');
const [message, setMessage] = useState('');
const isApproval = kind === 'approve';
async function accept() {
setState('loading');
try {
const result = await (isApproval ? selfApproveEmailChange(params.get('token')) : confirmEmailChange(params.get('token')));
setMessage(isApproval ? t('email_change_self_approved') : `${t('email_change_applied')} ${result.email}`);
setState('success');
} catch (err) { setMessage(err.message || t('common_error')); setState('error'); }
}
return <div className="login-shell"><div className="login-card">
<h1 className="login-title">{t(isApproval ? 'email_change_approve_title' : 'email_change_confirm_title')}</h1>
{state === 'ready' && <Button variant="accent" onClick={accept} disabled={!params.get('token')}>{t('email_change_accept')}</Button>}
{state === 'loading' && <div>{t('common_loading')}</div>}
{state === 'success' && <div className="login-info">{message}</div>}
{state === 'error' && <div className="login-error">{message}</div>}
<div className="login-links"><Link to="/login">{t('register_back_to_login')}</Link></div>
</div></div>;
}
@@ -0,0 +1,44 @@
import { useEffect, useState } from 'react';
import { useUi } from '../../context/ui-context.jsx';
import { Button } from '../../components/Button.jsx';
import { DetailPopup } from '../../components/DetailPopup.jsx';
import { Field } from '../../components/Field.jsx';
import { PasswordInput } from '../../components/PasswordInput.jsx';
import { useDraftForm } from '../../hooks/useDraftForm.js';
import { myEmailChange, requestEmailChange, cancelEmailChange } from '../../services/emailChange.js';
export function EmailChangeSection() {
const { t, pushToast } = useUi();
const [request, setRequest] = useState(null);
const [open, setOpen] = useState(false);
const [error, setError] = useState('');
const [busy, setBusy] = useState(false);
const draft = useDraftForm({ formId: 'account-email-change', initial: { newEmail: '', currentPassword: '' },
label: t('email_change_title'), enabled: open, exclude: ['currentPassword'] });
useEffect(() => { myEmailChange().then(setRequest).catch(() => {}); }, []);
async function submit(e) {
e.preventDefault(); setBusy(true); setError('');
try { await requestEmailChange(draft.values.newEmail, draft.values.currentPassword); draft.clear(); setOpen(false);
setRequest(await myEmailChange()); pushToast({ tone: 'success', message: t('email_change_requested') });
} catch (err) { setError(err.message || t('common_error')); } finally { setBusy(false); }
}
async function cancel() {
try { await cancelEmailChange(); setRequest(await myEmailChange()); pushToast({ tone: 'success', message: t('email_change_cancelled') }); }
catch (err) { setError(err.message || t('common_error')); }
}
const active = ['pending', 'approved'].includes(request?.status);
return <div className="card" style={{ padding: 20, marginTop: 16 }}>
<h3>{t('email_change_title')}</h3>
{request && <p>{t(`email_change_status_${request.status}`)}: {request.newEmail}{request.rejectReason ? ` — ${request.rejectReason}` : ''}</p>}
{error && <div className="login-error">{error}</div>}
{active ? <Button onClick={cancel}>{t('email_change_cancel')}</Button> : <Button variant="accent" onClick={() => setOpen(true)}>{t('email_change_title')}</Button>}
<DetailPopup open={open} title={t('email_change_title')} onClose={() => { draft.discard(); setOpen(false); }} width={480} height={380}>
<form onSubmit={submit}>
<Field label={t('email_change_new')}><input className="input" type="email" value={draft.values.newEmail} onChange={(e) => draft.setValues((v) => ({ ...v, newEmail: e.target.value }))} required /></Field>
<Field label={t('change_password_current')}><PasswordInput value={draft.values.currentPassword} onChange={(e) => draft.setValues((v) => ({ ...v, currentPassword: e.target.value }))} required /></Field>
{error && <div className="login-error">{error}</div>}
<Button type="submit" variant="accent" disabled={busy}>{t('email_change_request')}</Button>
</form>
</DetailPopup>
</div>;
}
+2 -1
View File
@@ -8,6 +8,7 @@ import { ComboBox } from '../../components/ComboBox.jsx';
import { Button } from '../../components/Button.jsx'; import { Button } from '../../components/Button.jsx';
import { PasswordInput } from '../../components/PasswordInput.jsx'; import { PasswordInput } from '../../components/PasswordInput.jsx';
import { SessionsList } from '../../components/SessionsList.jsx'; import { SessionsList } from '../../components/SessionsList.jsx';
import { EmailChangeSection } from './EmailChangeSection.jsx';
import { useDraftForm } from '../../hooks/useDraftForm.js'; import { useDraftForm } from '../../hooks/useDraftForm.js';
import { changePassword, listSessions, revokeSession, revokeOtherSessions } from '../../services/auth.js'; import { changePassword, listSessions, revokeSession, revokeOtherSessions } from '../../services/auth.js';
@@ -49,7 +50,7 @@ export default function MiCuentaPage() {
<Field label={t('user_email')}><div>{user?.email || '—'}</div></Field> <Field label={t('user_email')}><div>{user?.email || '—'}</div></Field>
<Field label={t('user_role')}><div>{user?.roleName || user?.roleCode || '—'}</div></Field> <Field label={t('user_role')}><div>{user?.roleName || user?.roleCode || '—'}</div></Field>
<Field label={t('common_department')}><div>{user?.departmentName || '—'}</div></Field> <Field label={t('common_department')}><div>{user?.departmentName || '—'}</div></Field>
</FieldRow></div>} </FieldRow><EmailChangeSection /></div>}
{tab === 'preferences' && <div className="card" style={{ padding: 20 }}><FieldRow> {tab === 'preferences' && <div className="card" style={{ padding: 20 }}><FieldRow>
<Field label={t('account_language')}><ComboBox value={lang} onChange={setLang} options={[{ value: 'es-EC', label: t('lang_es') }, { value: 'en-US', label: t('lang_en') }]} /></Field> <Field label={t('account_language')}><ComboBox value={lang} onChange={setLang} options={[{ value: 'es-EC', label: t('lang_es') }, { value: 'en-US', label: t('lang_en') }]} /></Field>
<Field label={t('account_theme')}><ComboBox value={theme} onChange={setTheme} options={[{ value: 'dark', label: t('theme_dark') }, { value: 'light', label: t('theme_light') }]} /></Field> <Field label={t('account_theme')}><ComboBox value={theme} onChange={setTheme} options={[{ value: 'dark', label: t('theme_dark') }, { value: 'light', label: t('theme_light') }]} /></Field>
@@ -0,0 +1,41 @@
import { useEffect, useState } from 'react';
import { Button } from '../../components/Button.jsx';
import { Field } from '../../components/Field.jsx';
import { DetailPopup } from '../../components/DetailPopup.jsx';
import { useDraftForm } from '../../hooks/useDraftForm.js';
import { useUi } from '../../context/ui-context.jsx';
import { pendingEmailChanges, approveEmailChange, rejectEmailChange } from '../../services/emailChange.js';
export function EmailChangeRequests() {
const { t, pushToast } = useUi();
const [rows, setRows] = useState([]);
const [rejecting, setRejecting] = useState(null);
const [error, setError] = useState('');
const draft = useDraftForm({ formId: 'email-change-reject', entityId: rejecting?.id,
initial: { reason: '' }, label: t('email_change_reject'), enabled: !!rejecting });
const reload = () => pendingEmailChanges().then(setRows).catch((err) => setError(err.message));
useEffect(() => { reload(); }, []);
async function approve(id) {
try { await approveEmailChange(id); reload(); pushToast({ tone: 'success', message: t('email_change_approved') }); }
catch (err) { setError(err.message); }
}
async function reject(e) {
e.preventDefault();
try { await rejectEmailChange(rejecting.id, draft.values.reason); draft.clear(); setRejecting(null); reload(); pushToast({ tone: 'success', message: t('email_change_rejected') }); }
catch (err) { setError(err.message); }
}
return <section className="card" style={{ padding: 20, marginTop: 16 }}>
<h3>{t('email_change_requests')} ({rows.length})</h3>
{error && <div className="login-error">{error}</div>}
{rows.length === 0 && <p className="text-muted">{t('email_change_none')}</p>}
{rows.map((r) => <div key={r.id} style={{ display: 'flex', flexWrap: 'wrap', gap: 12, alignItems: 'center', padding: 12, borderBottom: '1px solid rgb(var(--border))' }}>
<span style={{ flex: 1 }}>{r.name}: {r.oldEmail} → {r.newEmail}</span>
<Button onClick={() => approve(r.id)}>{t('email_change_approve')}</Button>
<Button onClick={() => setRejecting(r)}>{t('email_change_reject')}</Button>
</div>)}
<DetailPopup open={!!rejecting} title={t('email_change_reject')} onClose={() => { draft.discard(); setRejecting(null); }} width={480} height={300}>
<form onSubmit={reject}><Field label={t('email_change_reason')}><textarea className="input" value={draft.values.reason} onChange={(e) => draft.setValues({ reason: e.target.value })} maxLength={1000} required /></Field>
<Button type="submit" variant="accent">{t('email_change_reject')}</Button></form>
</DetailPopup>
</section>;
}
@@ -19,6 +19,7 @@ import { DraftMark, useGuardedClose } from '../../components/NavigationGuard.jsx
import { useDraftForm, useUserDrafts, hasDraftFor, useReopenDraft } from '../../hooks/useDraftForm.js'; import { useDraftForm, useUserDrafts, hasDraftFor, useReopenDraft } from '../../hooks/useDraftForm.js';
import { PasswordInput } from '../../components/PasswordInput.jsx'; import { PasswordInput } from '../../components/PasswordInput.jsx';
import { SessionsList } from '../../components/SessionsList.jsx'; import { SessionsList } from '../../components/SessionsList.jsx';
import { EmailChangeRequests } from './EmailChangeRequests.jsx';
function flatten(nodes, acc = []) { function flatten(nodes, acc = []) {
for (const n of nodes) { acc.push(n); if (n.children?.length) flatten(n.children, acc); } for (const n of nodes) { acc.push(n); if (n.children?.length) flatten(n.children, acc); }
@@ -164,6 +165,7 @@ export default function UsersPanel() {
{canManage && <Button variant="accent" icon={Plus} onClick={startNew}>{t('user_new')}{pendingNew && !editing && <DraftMark />}</Button>} {canManage && <Button variant="accent" icon={Plus} onClick={startNew}>{t('user_new')}{pendingNew && !editing && <DraftMark />}</Button>}
</div> </div>
{canManage && <EmailChangeRequests />}
<ListView <ListView
columns={columns} columns={columns}
rows={users} rows={users}
+1 -1
View File
@@ -13,7 +13,7 @@ function getToken() {
async function request(method, path, { body, isForm, extraHeaders, signal } = {}) { async function request(method, path, { body, isForm, extraHeaders, signal } = {}) {
const headers = { ...extraHeaders }; const headers = { ...extraHeaders };
const token = getToken(); const token = path.startsWith('/auth/email-change/') ? null : getToken();
// Nunca operar con un token vencido: se corta la sesion aqui mismo, sin enviar la peticion. // Nunca operar con un token vencido: se corta la sesion aqui mismo, sin enviar la peticion.
if (token && isTokenExpired(token)) { if (token && isTokenExpired(token)) {
onUnauthorized({ expired: true }); onUnauthorized({ expired: true });
+9
View File
@@ -0,0 +1,9 @@
import { api } from './api.js';
export const myEmailChange = () => api.get('/account/email-change');
export const requestEmailChange = (newEmail, currentPassword) => api.post('/account/email-change', { newEmail, currentPassword });
export const cancelEmailChange = () => api.delete('/account/email-change');
export const pendingEmailChanges = () => api.get('/users/email-change-requests');
export const approveEmailChange = (id) => api.post(`/users/email-change-requests/${id}/approve`);
export const rejectEmailChange = (id, reason) => api.post(`/users/email-change-requests/${id}/reject`, { reason });
export const selfApproveEmailChange = (token) => api.post('/auth/email-change/self-approve', { token });
export const confirmEmailChange = (token) => api.post('/auth/email-change/confirm', { token });
+2
View File
@@ -25,6 +25,8 @@ const DRAFT_FIELDS = {
'user-edit': ['editVersion', 'roleId', 'departmentId'], 'user-edit': ['editVersion', 'roleId', 'departmentId'],
'user-approve': ['editVersion', 'roleId', 'departmentId'], 'user-approve': ['editVersion', 'roleId', 'departmentId'],
'account-password': [], 'account-password': [],
'account-email-change': ['newEmail'],
'email-change-reject': ['reason'],
'recurring-new': ['projectId', 'responsibleUserId', 'intervalUnit', 'intervalValue', 'nextDueDate', 'endDate', 'reminderDaysBefore', 'reminderHour'], 'recurring-new': ['projectId', 'responsibleUserId', 'intervalUnit', 'intervalValue', 'nextDueDate', 'endDate', 'reminderDaysBefore', 'reminderHour'],
'recurring-edit': ['editVersion', 'projectId', 'responsibleUserId', 'intervalUnit', 'intervalValue', 'nextDueDate', 'endDate', 'reminderDaysBefore', 'reminderHour'], 'recurring-edit': ['editVersion', 'projectId', 'responsibleUserId', 'intervalUnit', 'intervalValue', 'nextDueDate', 'endDate', 'reminderDaysBefore', 'reminderHour'],
'device-edit': ['editVersion', 'status', 'assignedUserId', 'departmentId'], 'device-edit': ['editVersion', 'status', 'assignedUserId', 'departmentId'],