feat(admin): add email change flow with confirmation for users
- add `ConfirmEmailChangePage.client.js` for email change token confirmation - add `emailChange.js` core utility to generate and verify email change tokens - add `EmailChangeConfirmEmail.js` and `EmailChangeNotifyEmail.js` email templates - update `UserEditModal` to handle email changes with password verification for self-edits - update `ProfilePage` to support email change initiation - update `UsersPage` to pass `currentUserId` to `UserEditModal` - add email change API endpoints in `auth/api.js` and `auth/email.js` - register `ConfirmEmailChangePage` in `AdminPage.client.js`
This commit is contained in:
+166
-3
@@ -7,10 +7,13 @@
|
||||
* the context argument: (request, params, { session }).
|
||||
*/
|
||||
|
||||
import { query, updateById } from '@zen/core/database';
|
||||
import { query, updateById, findOne } from '@zen/core/database';
|
||||
import { updateUser } from './auth.js';
|
||||
import { verifyPassword } from '../../core/users/password.js';
|
||||
import { createEmailChangeToken, verifyEmailChangeToken, applyEmailChange, sendEmailChangeConfirmEmail, sendEmailChangeOldNotifyEmail, sendEmailChangeNewNotifyEmail } from './email.js';
|
||||
import { listRoles, getRoleById, createRole, updateRole, deleteRole, getUserRoles, assignUserRole, revokeUserRole } from '@zen/core/users';
|
||||
import { uploadImage, deleteFile, generateUniqueFilename, getFileExtension, FILE_TYPE_PRESETS, FILE_SIZE_LIMITS, validateUpload } from '@zen/core/storage';
|
||||
import { getPublicBaseUrl } from '@zen/core/shared/config';
|
||||
|
||||
const generateUserFilePath = (userId, category, filename) => `users/${userId}/${category}/${filename}`;
|
||||
import { fail, info } from '@zen/core/shared/logger';
|
||||
@@ -139,6 +142,163 @@ async function handleListUsers(request) {
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /zen/api/users/profile/email
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
async function handleInitiateEmailChange(request, _params, { session }) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { newEmail, password } = body;
|
||||
|
||||
if (!newEmail || !EMAIL_REGEX.test(newEmail) || newEmail.length > 254) {
|
||||
return apiError('Bad Request', 'Adresse courriel invalide');
|
||||
}
|
||||
|
||||
if (!password) {
|
||||
return apiError('Bad Request', 'Le mot de passe est requis');
|
||||
}
|
||||
|
||||
const normalizedEmail = newEmail.trim().toLowerCase();
|
||||
|
||||
if (normalizedEmail === session.user.email.toLowerCase()) {
|
||||
return apiError('Bad Request', 'Cette adresse courriel est déjà la vôtre');
|
||||
}
|
||||
|
||||
const existing = await findOne('zen_auth_users', { email: normalizedEmail });
|
||||
if (existing) {
|
||||
return apiError('Conflict', 'Cette adresse courriel est déjà utilisée');
|
||||
}
|
||||
|
||||
const account = await findOne('zen_auth_accounts', { user_id: session.user.id, provider_id: 'credential' });
|
||||
if (!account || !account.password) {
|
||||
return apiError('Bad Request', 'Impossible de vérifier le mot de passe');
|
||||
}
|
||||
|
||||
const passwordValid = await verifyPassword(password, account.password);
|
||||
if (!passwordValid) {
|
||||
return apiError('Unauthorized', 'Mot de passe incorrect');
|
||||
}
|
||||
|
||||
const token = await createEmailChangeToken(session.user.id, normalizedEmail);
|
||||
const baseUrl = getPublicBaseUrl();
|
||||
|
||||
try {
|
||||
await sendEmailChangeConfirmEmail(normalizedEmail, token, baseUrl);
|
||||
} catch (emailError) {
|
||||
fail(`handleInitiateEmailChange: failed to send confirmation email: ${emailError.message}`);
|
||||
return apiError('Internal Server Error', 'Impossible d\'envoyer le courriel de confirmation');
|
||||
}
|
||||
|
||||
try {
|
||||
await sendEmailChangeOldNotifyEmail(session.user.email, normalizedEmail, 'pending');
|
||||
} catch (emailError) {
|
||||
fail(`handleInitiateEmailChange: failed to send notification email: ${emailError.message}`);
|
||||
}
|
||||
|
||||
return apiSuccess({ success: true, message: `Un courriel de confirmation a été envoyé à ${normalizedEmail}` });
|
||||
} catch (error) {
|
||||
logAndObscureError(error, null);
|
||||
return apiError('Internal Server Error', 'Impossible d\'initier le changement de courriel');
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /zen/api/users/email/confirm
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function handleConfirmEmailChange(request, _params, { session }) {
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const token = url.searchParams.get('token');
|
||||
|
||||
if (!token) {
|
||||
return apiError('Bad Request', 'Jeton de confirmation manquant');
|
||||
}
|
||||
|
||||
const result = await verifyEmailChangeToken(token);
|
||||
if (!result) {
|
||||
return apiError('Bad Request', 'Lien de confirmation invalide ou expiré');
|
||||
}
|
||||
|
||||
const { userId, newEmail } = result;
|
||||
|
||||
if (userId !== session.user.id) {
|
||||
return apiError('Forbidden', 'Ce lien ne vous appartient pas');
|
||||
}
|
||||
|
||||
const existing = await findOne('zen_auth_users', { email: newEmail });
|
||||
if (existing) {
|
||||
return apiError('Conflict', 'Cette adresse courriel est déjà utilisée');
|
||||
}
|
||||
|
||||
await applyEmailChange(userId, newEmail);
|
||||
|
||||
return apiSuccess({ success: true, message: 'Adresse courriel mise à jour avec succès' });
|
||||
} catch (error) {
|
||||
logAndObscureError(error, null);
|
||||
return apiError('Internal Server Error', 'Impossible de confirmer le changement de courriel');
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PUT /zen/api/users/:id/email (admin only)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function handleAdminUpdateUserEmail(request, { id: userId }) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { newEmail } = body;
|
||||
|
||||
if (!newEmail || !EMAIL_REGEX.test(newEmail) || newEmail.length > 254) {
|
||||
return apiError('Bad Request', 'Adresse courriel invalide');
|
||||
}
|
||||
|
||||
const normalizedEmail = newEmail.trim().toLowerCase();
|
||||
|
||||
const targetUser = await findOne('zen_auth_users', { id: userId });
|
||||
if (!targetUser) {
|
||||
return apiError('Not Found', 'Utilisateur introuvable');
|
||||
}
|
||||
|
||||
if (normalizedEmail === targetUser.email.toLowerCase()) {
|
||||
return apiError('Bad Request', 'Cette adresse courriel est déjà celle de l\'utilisateur');
|
||||
}
|
||||
|
||||
const existing = await findOne('zen_auth_users', { email: normalizedEmail });
|
||||
if (existing) {
|
||||
return apiError('Conflict', 'Cette adresse courriel est déjà utilisée');
|
||||
}
|
||||
|
||||
const oldEmail = targetUser.email;
|
||||
await applyEmailChange(userId, normalizedEmail);
|
||||
|
||||
try {
|
||||
await sendEmailChangeOldNotifyEmail(oldEmail, normalizedEmail, 'changed');
|
||||
} catch (emailError) {
|
||||
fail(`handleAdminUpdateUserEmail: failed to notify old email ${oldEmail}: ${emailError.message}`);
|
||||
}
|
||||
|
||||
try {
|
||||
await sendEmailChangeNewNotifyEmail(normalizedEmail, oldEmail);
|
||||
} catch (emailError) {
|
||||
fail(`handleAdminUpdateUserEmail: failed to notify new email ${normalizedEmail}: ${emailError.message}`);
|
||||
}
|
||||
|
||||
const updated = await query(
|
||||
'SELECT id, email, name, role, image, email_verified, created_at FROM zen_auth_users WHERE id = $1',
|
||||
[userId]
|
||||
);
|
||||
|
||||
return apiSuccess({ success: true, user: updated.rows[0], message: 'Courriel mis à jour avec succès' });
|
||||
} catch (error) {
|
||||
logAndObscureError(error, null);
|
||||
return apiError('Internal Server Error', 'Impossible de mettre à jour le courriel');
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PUT /zen/api/users/profile
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -467,15 +627,18 @@ async function handleDeleteRole(_request, { id: roleId }) {
|
||||
// parameterised paths (/users/:id) so they match first.
|
||||
|
||||
export const routes = defineApiRoutes([
|
||||
{ path: '/users', method: 'GET', handler: handleListUsers, auth: 'admin' },
|
||||
{ path: '/users', method: 'GET', handler: handleListUsers, auth: 'admin' },
|
||||
{ path: '/users/profile', method: 'PUT', handler: handleUpdateProfile, auth: 'user' },
|
||||
{ path: '/users/profile/email', method: 'POST', handler: handleInitiateEmailChange, auth: 'user' },
|
||||
{ path: '/users/profile/picture', method: 'POST', handler: handleUploadProfilePicture, auth: 'user' },
|
||||
{ path: '/users/profile/picture', method: 'DELETE', handler: handleDeleteProfilePicture, auth: 'user' },
|
||||
{ path: '/users/email/confirm', method: 'GET', handler: handleConfirmEmailChange, auth: 'user' },
|
||||
{ path: '/users/:id/roles', method: 'GET', handler: handleGetUserRoles, auth: 'admin' },
|
||||
{ path: '/users/:id/roles', method: 'POST', handler: handleAssignUserRole, auth: 'admin' },
|
||||
{ path: '/users/:id/roles/:roleId', method: 'DELETE', handler: handleRevokeUserRole, auth: 'admin' },
|
||||
{ path: '/users/:id', method: 'GET', handler: handleGetUserById, auth: 'admin' },
|
||||
{ path: '/users/:id', method: 'GET', handler: handleGetUserById, auth: 'admin' },
|
||||
{ path: '/users/:id', method: 'PUT', handler: handleUpdateUserById, auth: 'admin' },
|
||||
{ path: '/users/:id/email', method: 'PUT', handler: handleAdminUpdateUserEmail, auth: 'admin' },
|
||||
{ path: '/roles', method: 'GET', handler: handleListRoles, auth: 'admin' },
|
||||
{ path: '/roles', method: 'POST', handler: handleCreateRole, auth: 'admin' },
|
||||
{ path: '/roles/:id', method: 'GET', handler: handleGetRole, auth: 'admin' },
|
||||
|
||||
@@ -4,10 +4,15 @@ import { sendEmail } from '@zen/core/email';
|
||||
import { VerificationEmail } from './templates/VerificationEmail.js';
|
||||
import { PasswordResetEmail } from './templates/PasswordResetEmail.js';
|
||||
import { PasswordChangedEmail } from './templates/PasswordChangedEmail.js';
|
||||
import { EmailChangeConfirmEmail } from './templates/EmailChangeConfirmEmail.js';
|
||||
import { EmailChangeNotifyEmail } from './templates/EmailChangeNotifyEmail.js';
|
||||
|
||||
export { createEmailVerification, verifyEmailToken, createPasswordReset, verifyResetToken, deleteResetToken }
|
||||
from '../../core/users/verifications.js';
|
||||
|
||||
export { createEmailChangeToken, verifyEmailChangeToken, applyEmailChange }
|
||||
from '../../core/users/emailChange.js';
|
||||
|
||||
async function sendVerificationEmail(email, token, baseUrl) {
|
||||
const appName = process.env.ZEN_NAME || 'ZEN';
|
||||
const verificationUrl = `${baseUrl}/auth/confirm?email=${encodeURIComponent(email)}&token=${token}`;
|
||||
@@ -46,4 +51,46 @@ async function sendPasswordChangedEmail(email) {
|
||||
return result;
|
||||
}
|
||||
|
||||
export { sendVerificationEmail, sendPasswordResetEmail, sendPasswordChangedEmail };
|
||||
async function sendEmailChangeConfirmEmail(newEmail, token, baseUrl) {
|
||||
const appName = process.env.ZEN_NAME || 'ZEN';
|
||||
const confirmUrl = `${baseUrl}/admin/confirm-email-change?token=${encodeURIComponent(token)}`;
|
||||
const html = await render(<EmailChangeConfirmEmail confirmUrl={confirmUrl} newEmail={newEmail} companyName={appName} />);
|
||||
const result = await sendEmail({ to: newEmail, subject: `Confirmez votre nouvelle adresse courriel – ${appName}`, html });
|
||||
if (!result.success) {
|
||||
fail(`Auth: failed to send email change confirmation to ${newEmail}: ${result.error}`);
|
||||
throw new Error('Failed to send email change confirmation');
|
||||
}
|
||||
info(`Auth: email change confirmation sent to ${newEmail}`);
|
||||
return result;
|
||||
}
|
||||
|
||||
async function sendEmailChangeOldNotifyEmail(oldEmail, newEmail, variant) {
|
||||
const appName = process.env.ZEN_NAME || 'ZEN';
|
||||
const subjects = {
|
||||
pending: `Demande de modification de courriel – ${appName}`,
|
||||
changed: `Votre adresse courriel a été modifiée – ${appName}`,
|
||||
};
|
||||
const subject = subjects[variant] || subjects.changed;
|
||||
const html = await render(<EmailChangeNotifyEmail oldEmail={oldEmail} newEmail={newEmail} variant={variant} companyName={appName} />);
|
||||
const result = await sendEmail({ to: oldEmail, subject, html });
|
||||
if (!result.success) {
|
||||
fail(`Auth: failed to send email change notification to ${oldEmail}: ${result.error}`);
|
||||
throw new Error('Failed to send email change notification');
|
||||
}
|
||||
info(`Auth: email change notification (${variant}) sent to ${oldEmail}`);
|
||||
return result;
|
||||
}
|
||||
|
||||
async function sendEmailChangeNewNotifyEmail(newEmail, oldEmail) {
|
||||
const appName = process.env.ZEN_NAME || 'ZEN';
|
||||
const html = await render(<EmailChangeNotifyEmail oldEmail={oldEmail} newEmail={newEmail} variant="admin_new" companyName={appName} />);
|
||||
const result = await sendEmail({ to: newEmail, subject: `Votre compte est maintenant associé à cette adresse – ${appName}`, html });
|
||||
if (!result.success) {
|
||||
fail(`Auth: failed to send email change welcome to ${newEmail}: ${result.error}`);
|
||||
throw new Error('Failed to send email change welcome');
|
||||
}
|
||||
info(`Auth: email change welcome sent to ${newEmail}`);
|
||||
return result;
|
||||
}
|
||||
|
||||
export { sendVerificationEmail, sendPasswordResetEmail, sendPasswordChangedEmail, sendEmailChangeConfirmEmail, sendEmailChangeOldNotifyEmail, sendEmailChangeNewNotifyEmail };
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Button, Section, Text, Link } from "@react-email/components";
|
||||
import { BaseLayout } from "@zen/core/email/templates";
|
||||
|
||||
export const EmailChangeConfirmEmail = ({ confirmUrl, newEmail, companyName }) => (
|
||||
<BaseLayout
|
||||
preview={`Confirmez votre nouvelle adresse courriel – ${companyName}`}
|
||||
title="Confirmez votre nouvelle adresse courriel"
|
||||
companyName={companyName}
|
||||
supportSection={true}
|
||||
>
|
||||
<Text className="text-[14px] leading-[24px] text-neutral-600 mt-[4px] mb-[24px]">
|
||||
Une demande de modification d'adresse courriel a été effectuée sur votre compte{' '}
|
||||
<span className="font-medium text-neutral-900">{companyName}</span>. Cliquez sur le bouton ci-dessous pour confirmer votre nouvelle adresse.
|
||||
</Text>
|
||||
|
||||
<Section style={{ border: '1px solid #E5E5E5' }} className="bg-neutral-100 rounded-[12px] p-[20px] my-[24px]">
|
||||
<Text className="text-[12px] font-medium text-neutral-400 m-0 mb-[4px] uppercase tracking-wider">
|
||||
Nouvelle adresse
|
||||
</Text>
|
||||
<Text className="text-[14px] font-medium text-neutral-900 m-0">
|
||||
{newEmail}
|
||||
</Text>
|
||||
</Section>
|
||||
|
||||
<Section className="mt-[28px] mb-[32px]">
|
||||
<Button
|
||||
href={confirmUrl}
|
||||
className="bg-neutral-900 rounded-[8px] text-white font-medium px-[20px] py-[11px] no-underline text-[13px]"
|
||||
>
|
||||
Confirmer mon adresse courriel
|
||||
</Button>
|
||||
</Section>
|
||||
|
||||
<Text className="text-[12px] leading-[20px] text-neutral-400 m-0">
|
||||
Ce lien expire dans 24 heures. Si vous n'êtes pas à l'origine de cette demande, ignorez ce message — votre adresse actuelle restera inchangée.
|
||||
</Text>
|
||||
|
||||
<Text className="text-[12px] leading-[20px] text-neutral-400 m-0 mt-[8px]">
|
||||
Lien :{' '}
|
||||
<Link href={confirmUrl} className="text-neutral-400 underline break-all">
|
||||
{confirmUrl}
|
||||
</Link>
|
||||
</Text>
|
||||
</BaseLayout>
|
||||
);
|
||||
@@ -0,0 +1,63 @@
|
||||
import { Section, Text } from "@react-email/components";
|
||||
import { BaseLayout } from "@zen/core/email/templates";
|
||||
|
||||
const VARIANTS = {
|
||||
pending: {
|
||||
preview: (name) => `Demande de modification de courriel – ${name}`,
|
||||
title: 'Demande de modification de courriel',
|
||||
body: (name) => `Une demande de modification de l'adresse courriel associée à votre compte ${name} a été initiée.`,
|
||||
note: "Si vous n'êtes pas à l'origine de cette demande, contactez immédiatement notre équipe de support. Votre adresse actuelle reste active jusqu'à confirmation.",
|
||||
},
|
||||
changed: {
|
||||
preview: (name) => `Votre adresse courriel a été modifiée – ${name}`,
|
||||
title: 'Adresse courriel modifiée',
|
||||
body: (name) => `L'adresse courriel de votre compte ${name} a été modifiée par un administrateur.`,
|
||||
note: "Si vous n'êtes pas à l'origine de cette modification, contactez immédiatement notre équipe de support.",
|
||||
},
|
||||
admin_new: {
|
||||
preview: (name) => `Votre compte est maintenant associé à cette adresse – ${name}`,
|
||||
title: 'Adresse courriel associée à votre compte',
|
||||
body: (name) => `Votre adresse courriel est maintenant associée à un compte ${name}. Cette modification a été effectuée par un administrateur.`,
|
||||
note: "Si vous n'avez pas été informé de cette modification, contactez notre équipe de support.",
|
||||
},
|
||||
};
|
||||
|
||||
export const EmailChangeNotifyEmail = ({ oldEmail, newEmail, variant = 'changed', companyName }) => {
|
||||
const msg = VARIANTS[variant] || VARIANTS.changed;
|
||||
|
||||
return (
|
||||
<BaseLayout
|
||||
preview={msg.preview(companyName)}
|
||||
title={msg.title}
|
||||
companyName={companyName}
|
||||
supportSection={true}
|
||||
>
|
||||
<Text className="text-[14px] leading-[24px] text-neutral-600 mt-[4px] mb-[24px]">
|
||||
{msg.body(companyName)}
|
||||
</Text>
|
||||
|
||||
<Section style={{ border: '1px solid #E5E5E5' }} className="bg-neutral-100 rounded-[12px] p-[20px] my-[24px]">
|
||||
{oldEmail && variant !== 'admin_new' && (
|
||||
<>
|
||||
<Text className="text-[12px] font-medium text-neutral-400 m-0 mb-[4px] uppercase tracking-wider">
|
||||
Ancienne adresse
|
||||
</Text>
|
||||
<Text className="text-[14px] font-medium text-neutral-900 m-0 mb-[12px]">
|
||||
{oldEmail}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
<Text className="text-[12px] font-medium text-neutral-400 m-0 mb-[4px] uppercase tracking-wider">
|
||||
Nouvelle adresse
|
||||
</Text>
|
||||
<Text className="text-[14px] font-medium text-neutral-900 m-0">
|
||||
{newEmail}
|
||||
</Text>
|
||||
</Section>
|
||||
|
||||
<Text className="text-[12px] leading-[20px] text-neutral-400 m-0">
|
||||
{msg.note}
|
||||
</Text>
|
||||
</BaseLayout>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user