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:
2026-04-24 15:04:36 -04:00
parent f31b97cff4
commit 66c862cf73
10 changed files with 623 additions and 21 deletions
+166 -3
View File
@@ -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' },