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:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "project-linkedcorp-backend",
|
||||
"version": "4.5.0",
|
||||
"version": "4.6.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/server.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);
|
||||
|
||||
@@ -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))));
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -18,3 +18,13 @@ export function recoveryEmail(name, 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>`;
|
||||
}
|
||||
|
||||
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>`;
|
||||
}
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "project-linkedcorp-frontend",
|
||||
"private": true,
|
||||
"version": "4.5.0",
|
||||
"version": "4.6.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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(
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/registro" element={<RegisterPage />} />
|
||||
<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="/restablecer-clave" element={<ResetPasswordPage />} />
|
||||
<Route path="/restablecer-pin" element={<ResetVaultPinPage />} />
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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() {
|
||||
<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('common_department')}><div>{user?.departmentName || '—'}</div></Field>
|
||||
</FieldRow></div>}
|
||||
</FieldRow><EmailChangeSection /></div>}
|
||||
{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_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 { PasswordInput } from '../../components/PasswordInput.jsx';
|
||||
import { SessionsList } from '../../components/SessionsList.jsx';
|
||||
import { EmailChangeRequests } from './EmailChangeRequests.jsx';
|
||||
|
||||
function flatten(nodes, 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>}
|
||||
</div>
|
||||
|
||||
{canManage && <EmailChangeRequests />}
|
||||
<ListView
|
||||
columns={columns}
|
||||
rows={users}
|
||||
|
||||
@@ -13,7 +13,7 @@ function getToken() {
|
||||
|
||||
async function request(method, path, { body, isForm, extraHeaders, signal } = {}) {
|
||||
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.
|
||||
if (token && isTokenExpired(token)) {
|
||||
onUnauthorized({ expired: true });
|
||||
|
||||
@@ -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 });
|
||||
@@ -25,6 +25,8 @@ const DRAFT_FIELDS = {
|
||||
'user-edit': ['editVersion', 'roleId', 'departmentId'],
|
||||
'user-approve': ['editVersion', 'roleId', 'departmentId'],
|
||||
'account-password': [],
|
||||
'account-email-change': ['newEmail'],
|
||||
'email-change-reject': ['reason'],
|
||||
'recurring-new': ['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'],
|
||||
|
||||
Reference in New Issue
Block a user