diff --git a/AGENTS.md b/AGENTS.md index 2d7bc90..67203b6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -107,6 +107,7 @@ Adaptación web (no es una app empaquetada, corre en pestaña de navegador): ## Seguridad (proyecto con auth/usuarios) - 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 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 @@ -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 loguean en la consola del backend en vez de enviarse (nunca rompe el flujo). + ## Regla de UX para pantallas - No mezclar listado y formulario de creación en la vista inicial de un módulo. diff --git a/API.md b/API.md index a395c06..33a4cea 100644 --- a/API.md +++ b/API.md @@ -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) - `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) + +## 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. diff --git a/BASE_DE_DATOS.md b/BASE_DE_DATOS.md index 7273b54..b0778c8 100644 --- a/BASE_DE_DATOS.md +++ b/BASE_DE_DATOS.md @@ -240,3 +240,7 @@ en producción. 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 (`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`. diff --git a/CHANGELOG.md b/CHANGELOG.md index d87ca1f..9e24c40 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # 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 ### Added - Calendario: capa de tareas recurrentes con sus próximos vencimientos (el pendiente real y las diff --git a/ESTADO.md b/ESTADO.md index 5eb6d1d..b57b248 100644 --- a/ESTADO.md +++ b/ESTADO.md @@ -1,9 +1,19 @@ # 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 + 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) - v4.5.0: calendario personal para todos los roles (ver SPEC §7), capas Proyectos/Actividades/ diff --git a/SPEC.md b/SPEC.md index 05ce537..5fe74dc 100644 --- a/SPEC.md +++ b/SPEC.md @@ -25,6 +25,9 @@ tiene cada cosa — sin que cargar la información sea tedioso para quien lo usa ## Modulos (MVP) ### 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`. 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 diff --git a/VERSION b/VERSION index a84947d..6016e8a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -4.5.0 +4.6.0 diff --git a/backend/database/migrations/035_email_change_requests.sql b/backend/database/migrations/035_email_change_requests.sql new file mode 100644 index 0000000..977031a --- /dev/null +++ b/backend/database/migrations/035_email_change_requests.sql @@ -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; diff --git a/backend/package.json b/backend/package.json index 04656b7..3a810c5 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "project-linkedcorp-backend", - "version": "4.5.0", + "version": "4.6.0", "private": true, "type": "module", "main": "src/server.js", diff --git a/backend/src/app.js b/backend/src/app.js index 66d70c6..23b5cbc 100644 --- a/backend/src/app.js +++ b/backend/src/app.js @@ -5,6 +5,7 @@ import { env } from './config/env.js'; import { notFoundHandler, errorHandler } from './middleware/errorHandler.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 departmentsRoutes from './modules/departments/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.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/users', usersRoutes); app.use('/api/departments', departmentsRoutes); diff --git a/backend/src/modules/email-change/routes.js b/backend/src/modules/email-change/routes.js new file mode 100644 index 0000000..e89becd --- /dev/null +++ b/backend/src/modules/email-change/routes.js @@ -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)))); diff --git a/backend/src/modules/email-change/service.js b/backend/src/modules/email-change/service.js new file mode 100644 index 0000000..0cd4df9 --- /dev/null +++ b/backend/src/modules/email-change/service.js @@ -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 }; +} diff --git a/backend/src/utils/emailHtml.js b/backend/src/utils/emailHtml.js index af0dae1..ee88435 100644 --- a/backend/src/utils/emailHtml.js +++ b/backend/src/utils/emailHtml.js @@ -18,3 +18,13 @@ export function recoveryEmail(name, url) { const safeUrl = escapeHtml(url); return `

Hola ${escapeHtml(name)},

Para elegir una nueva clave entra a este enlace (vence en 1 hora):

${safeUrl}

Si no pediste esto, ignora el correo.

`; } + +export function emailChangeLinkEmail(name, url, purpose) { + const action = purpose === 'approve' ? 'aprobar la solicitud desde tu correo actual' : 'confirmar tu correo nuevo'; + return `

Hola ${escapeHtml(name)},

Para ${action}, abre este enlace:

${escapeHtml(url)}

Vence en 24 horas. Si no solicitaste el cambio, contacta al administrador.

`; +} + +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 `

Hola ${escapeHtml(name)},

${message}.

Si no fuiste tú, contacta al administrador.

`; +} diff --git a/docs/SESION-Y-BORRADORES.md b/docs/SESION-Y-BORRADORES.md index d0f6e50..01d4b6c 100644 --- a/docs/SESION-Y-BORRADORES.md +++ b/docs/SESION-Y-BORRADORES.md @@ -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. | | 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. | | 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. | diff --git a/frontend/package.json b/frontend/package.json index 00ae21e..69f6508 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "project-linkedcorp-frontend", "private": true, - "version": "4.5.0", + "version": "4.6.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/app/App.jsx b/frontend/src/app/App.jsx index 0919201..e74e8d5 100644 --- a/frontend/src/app/App.jsx +++ b/frontend/src/app/App.jsx @@ -10,6 +10,7 @@ import AppShell from './AppShell.jsx'; import LoginPage from '../pages/Login/LoginPage.jsx'; import RegisterPage from '../pages/Login/RegisterPage.jsx'; import VerifyEmailPage from '../pages/Login/VerifyEmailPage.jsx'; +import EmailChangeLinkPage from '../pages/Login/EmailChangeLinkPage.jsx'; import ForgotPasswordPage from '../pages/Login/ForgotPasswordPage.jsx'; import ResetPasswordPage from '../pages/Login/ResetPasswordPage.jsx'; import AcceptInvitePage from '../pages/Login/AcceptInvitePage.jsx'; @@ -33,6 +34,8 @@ const router = createBrowserRouter(createRoutesFromElements( } /> } /> } /> + } /> + } /> } /> } /> } /> diff --git a/frontend/src/i18n/messages.js b/frontend/src/i18n/messages.js index 0f11eca..2e03bee 100644 --- a/frontend/src/i18n/messages.js +++ b/frontend/src/i18n/messages.js @@ -1,5 +1,6 @@ export const messages = { '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', 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', @@ -625,6 +626,7 @@ export const messages = { notifications_mark_all_read: 'Marcar todas como leidas', }, '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', 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', diff --git a/frontend/src/pages/Login/EmailChangeLinkPage.jsx b/frontend/src/pages/Login/EmailChangeLinkPage.jsx new file mode 100644 index 0000000..6f37531 --- /dev/null +++ b/frontend/src/pages/Login/EmailChangeLinkPage.jsx @@ -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
+

{t(isApproval ? 'email_change_approve_title' : 'email_change_confirm_title')}

+ {state === 'ready' && } + {state === 'loading' &&
{t('common_loading')}
} + {state === 'success' &&
{message}
} + {state === 'error' &&
{message}
} +
{t('register_back_to_login')}
+
; +} diff --git a/frontend/src/pages/MiCuenta/EmailChangeSection.jsx b/frontend/src/pages/MiCuenta/EmailChangeSection.jsx new file mode 100644 index 0000000..b84b5bb --- /dev/null +++ b/frontend/src/pages/MiCuenta/EmailChangeSection.jsx @@ -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
+

{t('email_change_title')}

+ {request &&

{t(`email_change_status_${request.status}`)}: {request.newEmail}{request.rejectReason ? ` — ${request.rejectReason}` : ''}

} + {error &&
{error}
} + {active ? : } + { draft.discard(); setOpen(false); }} width={480} height={380}> +
+ draft.setValues((v) => ({ ...v, newEmail: e.target.value }))} required /> + draft.setValues((v) => ({ ...v, currentPassword: e.target.value }))} required /> + {error &&
{error}
} + +
+
+
; +} diff --git a/frontend/src/pages/MiCuenta/MiCuentaPage.jsx b/frontend/src/pages/MiCuenta/MiCuentaPage.jsx index 2fb619a..a5b135a 100644 --- a/frontend/src/pages/MiCuenta/MiCuentaPage.jsx +++ b/frontend/src/pages/MiCuenta/MiCuentaPage.jsx @@ -8,6 +8,7 @@ import { ComboBox } from '../../components/ComboBox.jsx'; import { Button } from '../../components/Button.jsx'; import { PasswordInput } from '../../components/PasswordInput.jsx'; import { SessionsList } from '../../components/SessionsList.jsx'; +import { EmailChangeSection } from './EmailChangeSection.jsx'; import { useDraftForm } from '../../hooks/useDraftForm.js'; import { changePassword, listSessions, revokeSession, revokeOtherSessions } from '../../services/auth.js'; @@ -49,7 +50,7 @@ export default function MiCuentaPage() {
{user?.email || '—'}
{user?.roleName || user?.roleCode || '—'}
{user?.departmentName || '—'}
- } + } {tab === 'preferences' &&
diff --git a/frontend/src/pages/Organizacion/EmailChangeRequests.jsx b/frontend/src/pages/Organizacion/EmailChangeRequests.jsx new file mode 100644 index 0000000..0a2c9fb --- /dev/null +++ b/frontend/src/pages/Organizacion/EmailChangeRequests.jsx @@ -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
+

{t('email_change_requests')} ({rows.length})

+ {error &&
{error}
} + {rows.length === 0 &&

{t('email_change_none')}

} + {rows.map((r) =>
+ {r.name}: {r.oldEmail} → {r.newEmail} + + +
)} + { draft.discard(); setRejecting(null); }} width={480} height={300}> +