diff --git a/src/core/users/emailChange.js b/src/core/users/emailChange.js new file mode 100644 index 0000000..488c9ca --- /dev/null +++ b/src/core/users/emailChange.js @@ -0,0 +1,62 @@ +import crypto from 'crypto'; +import { query, create, deleteWhere, updateById } from '@zen/core/database'; +import { generateToken, generateId } from './password.js'; + +export async function createEmailChangeToken(userId, newEmail) { + const token = generateToken(32); + const expiresAt = new Date(); + expiresAt.setHours(expiresAt.getHours() + 24); + + await deleteWhere('zen_auth_verifications', { identifier: 'email_change:' + userId }); + + await create('zen_auth_verifications', { + id: generateId(), + identifier: 'email_change:' + userId, + value: newEmail, + token, + expires_at: expiresAt, + updated_at: new Date(), + }); + + return token; +} + +export async function verifyEmailChangeToken(token) { + const result = await query( + "SELECT * FROM zen_auth_verifications WHERE identifier LIKE 'email_change:%' AND token = $1", + [token] + ); + + if (result.rows.length === 0) return null; + + const record = result.rows[0]; + + // Timing-safe comparison — same-length buffer padding prevents length-based timing leaks. + const storedBuf = Buffer.from(record.token, 'utf8'); + const providedBuf = Buffer.from( + token.length === record.token.length ? token : record.token, + 'utf8' + ); + const tokensMatch = crypto.timingSafeEqual(storedBuf, providedBuf) && token.length === record.token.length; + if (!tokensMatch) return null; + + if (new Date(record.expires_at) < new Date()) { + await deleteWhere('zen_auth_verifications', { id: record.id }); + return null; + } + + const userId = record.identifier.slice('email_change:'.length); + const newEmail = record.value; + + await deleteWhere('zen_auth_verifications', { id: record.id }); + + return { userId, newEmail }; +} + +export async function applyEmailChange(userId, newEmail) { + await updateById('zen_auth_users', userId, { email: newEmail, updated_at: new Date() }); + await query( + 'UPDATE zen_auth_accounts SET account_id = $1, updated_at = $2 WHERE user_id = $3 AND provider_id = $4', + [newEmail, new Date(), userId, 'credential'] + ); +} diff --git a/src/features/admin/AdminPage.client.js b/src/features/admin/AdminPage.client.js index 888f0bf..388283b 100644 --- a/src/features/admin/AdminPage.client.js +++ b/src/features/admin/AdminPage.client.js @@ -6,6 +6,7 @@ import './pages/UsersPage.client.js'; import './pages/RolesPage.client.js'; import './pages/ProfilePage.client.js'; import './pages/SettingsPage.client.js'; +import './pages/ConfirmEmailChangePage.client.js'; import './widgets/index.client.js'; export default function AdminPageClient({ params, user, widgetData, appConfig }) { diff --git a/src/features/admin/components/UserEditModal.client.js b/src/features/admin/components/UserEditModal.client.js index 0d71512..e7235b6 100644 --- a/src/features/admin/components/UserEditModal.client.js +++ b/src/features/admin/components/UserEditModal.client.js @@ -4,14 +4,15 @@ import { useState, useEffect } from 'react'; import { Input, Select, TagInput, Modal } from '@zen/core/shared/components'; import { useToast } from '@zen/core/toast'; -const UserEditModal = ({ userId, isOpen, onClose, onSaved }) => { +const UserEditModal = ({ userId, currentUserId, isOpen, onClose, onSaved }) => { const toast = useToast(); + const isSelf = userId && currentUserId && userId === currentUserId; const [userData, setUserData] = useState(null); const [loading, setLoading] = useState(false); const [saving, setSaving] = useState(false); - const [formData, setFormData] = useState({ name: '', email_verified: 'false' }); + const [formData, setFormData] = useState({ name: '', email: '', email_verified: 'false', currentPassword: '' }); const [errors, setErrors] = useState({}); const [allRoles, setAllRoles] = useState([]); @@ -47,7 +48,9 @@ const UserEditModal = ({ userId, isOpen, onClose, onSaved }) => { setUserData(userJson.user); setFormData({ name: userJson.user.name || '', + email: userJson.user.email || '', email_verified: userJson.user.email_verified ? 'true' : 'false', + currentPassword: '', }); } else { toast.error(userJson.message || 'Utilisateur introuvable'); @@ -73,9 +76,12 @@ const UserEditModal = ({ userId, isOpen, onClose, onSaved }) => { if (errors[field]) setErrors(prev => ({ ...prev, [field]: null })); }; + const emailChanged = userData && formData.email !== userData.email; + const validate = () => { const newErrors = {}; if (!formData.name?.trim()) newErrors.name = 'Le nom est requis'; + if (emailChanged && isSelf && !formData.currentPassword) newErrors.currentPassword = 'Le mot de passe est requis pour changer le courriel'; setErrors(newErrors); return Object.keys(newErrors).length === 0; }; @@ -120,7 +126,43 @@ const UserEditModal = ({ userId, isOpen, onClose, onSaved }) => { ), ]); - toast.success('Utilisateur mis à jour'); + if (emailChanged) { + if (isSelf) { + const emailRes = await fetch('/zen/api/users/profile/email', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ newEmail: formData.email.trim(), password: formData.currentPassword }), + }); + const emailData = await emailRes.json(); + if (!emailRes.ok) { + toast.error(emailData.error || emailData.message || 'Impossible de changer le courriel'); + onSaved?.(); + onClose(); + return; + } + toast.success('Utilisateur mis à jour'); + toast.info(emailData.message || 'Un courriel de confirmation a été envoyé'); + } else { + const emailRes = await fetch(`/zen/api/users/${userId}/email`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ newEmail: formData.email.trim() }), + }); + const emailData = await emailRes.json(); + if (!emailRes.ok) { + toast.error(emailData.error || emailData.message || 'Impossible de changer le courriel'); + onSaved?.(); + onClose(); + return; + } + toast.success('Utilisateur mis à jour'); + } + } else { + toast.success('Utilisateur mis à jour'); + } + onSaved?.(); onClose(); } catch { @@ -165,11 +207,24 @@ const UserEditModal = ({ userId, isOpen, onClose, onSaved }) => { error={errors.name} /> handleInputChange('email', value)} + placeholder="courriel@exemple.com" /> + {isSelf && emailChanged && ( + handleInputChange('currentPassword', value)} + placeholder="Votre mot de passe" + error={errors.currentPassword} + description="Requis pour confirmer le changement de courriel" + /> + )} +
+ + {pendingEmailMessage ? ( +

{pendingEmailMessage}

+ ) : ( + + )} + {emailFormOpen && ( +
+ setEmailFormData(prev => ({ ...prev, newEmail: value }))} + placeholder="nouvelle@adresse.com" + required + disabled={emailLoading} + /> + setEmailFormData(prev => ({ ...prev, password: value }))} + placeholder="Votre mot de passe" + required + disabled={emailLoading} + /> +
+ + +
+
+ )} +
{ +const UsersPageClient = ({ currentUserId }) => { const toast = useToast(); const [users, setUsers] = useState([]); const [loading, setLoading] = useState(true); @@ -164,6 +164,7 @@ const UsersPageClient = () => { setEditingUserId(null)} onSaved={fetchUsers} @@ -172,10 +173,10 @@ const UsersPageClient = () => { ); }; -const UsersPage = () => ( +const UsersPage = ({ user }) => (
- +
); diff --git a/src/features/auth/api.js b/src/features/auth/api.js index f146f42..3203dd2 100644 --- a/src/features/auth/api.js +++ b/src/features/auth/api.js @@ -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' }, diff --git a/src/features/auth/email.js b/src/features/auth/email.js index a2a94d9..c8a4b62 100644 --- a/src/features/auth/email.js +++ b/src/features/auth/email.js @@ -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(); + 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(); + 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(); + 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 }; diff --git a/src/features/auth/templates/EmailChangeConfirmEmail.js b/src/features/auth/templates/EmailChangeConfirmEmail.js new file mode 100644 index 0000000..7e861e9 --- /dev/null +++ b/src/features/auth/templates/EmailChangeConfirmEmail.js @@ -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 }) => ( + + + Une demande de modification d'adresse courriel a été effectuée sur votre compte{' '} + {companyName}. Cliquez sur le bouton ci-dessous pour confirmer votre nouvelle adresse. + + +
+ + Nouvelle adresse + + + {newEmail} + +
+ +
+ +
+ + + 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. + + + + Lien :{' '} + + {confirmUrl} + + +
+); diff --git a/src/features/auth/templates/EmailChangeNotifyEmail.js b/src/features/auth/templates/EmailChangeNotifyEmail.js new file mode 100644 index 0000000..6999ee7 --- /dev/null +++ b/src/features/auth/templates/EmailChangeNotifyEmail.js @@ -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 ( + + + {msg.body(companyName)} + + +
+ {oldEmail && variant !== 'admin_new' && ( + <> + + Ancienne adresse + + + {oldEmail} + + + )} + + Nouvelle adresse + + + {newEmail} + +
+ + + {msg.note} + +
+ ); +};