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
@@ -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}
/>
<Input
label="Email"
value={userData?.email || ''}
disabled
label="Courriel"
type="email"
value={formData.email}
onChange={(value) => handleInputChange('email', value)}
placeholder="courriel@exemple.com"
/>
</div>
{isSelf && emailChanged && (
<Input
label="Mot de passe actuel *"
type="password"
value={formData.currentPassword}
onChange={(value) => handleInputChange('currentPassword', value)}
placeholder="Votre mot de passe"
error={errors.currentPassword}
description="Requis pour confirmer le changement de courriel"
/>
)}
<Select
label="Email vérifié"