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
+3
View File
@@ -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 />} />
+2
View File
@@ -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>;
}
+2 -1
View File
@@ -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}
+1 -1
View File
@@ -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 });
+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-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'],