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
+1
View File
@@ -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 }) {
@@ -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é"
@@ -0,0 +1,87 @@
'use client';
import { registerPage } from '../registry.js';
import { useState, useEffect, useRef } from 'react';
import { useSearchParams } from 'next/navigation';
import { Card } from '@zen/core/shared/components';
const ConfirmEmailChangePage = () => {
const searchParams = useSearchParams();
const token = searchParams.get('token');
const [isLoading, setIsLoading] = useState(true);
const [success, setSuccess] = useState('');
const [error, setError] = useState('');
const hasConfirmedRef = useRef(false);
useEffect(() => {
if (!token) {
setError('Lien de confirmation invalide.');
setIsLoading(false);
return;
}
if (hasConfirmedRef.current) return;
hasConfirmedRef.current = true;
fetch(`/zen/api/users/email/confirm?token=${encodeURIComponent(token)}`, { credentials: 'include' })
.then(res => res.json().then(data => ({ ok: res.ok, data })))
.then(({ ok, data }) => {
if (ok && data.success) {
setSuccess('Votre adresse courriel a été mise à jour avec succès.');
setTimeout(() => { window.location.href = '/admin/profile'; }, 3000);
} else {
setError(data.error || data.message || 'Lien de confirmation invalide ou expiré.');
}
})
.catch(() => setError('Une erreur inattendue est survenue.'))
.finally(() => setIsLoading(false));
}, [token]);
return (
<div className="flex items-center justify-center min-h-64">
<Card variant="default" padding="md" spacing="none" className="w-full max-w-md">
<div className="text-center mb-6">
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white mb-2">
Confirmation du courriel
</h1>
<p className="text-sm text-neutral-600 dark:text-neutral-400">
Validation de votre nouvelle adresse courriel...
</p>
</div>
{isLoading && (
<div className="flex flex-col items-center py-10">
<div className="w-12 h-12 border-4 border-neutral-300 border-t-neutral-900 rounded-full animate-spin dark:border-neutral-700/50 dark:border-t-white" />
<p className="mt-5 text-neutral-600 dark:text-neutral-400 text-sm">Confirmation en cours...</p>
</div>
)}
{success && (
<>
<div className="mb-4 p-3 bg-green-50 border border-green-200 rounded-lg dark:bg-green-500/10 dark:border-green-500/20">
<div className="flex items-center space-x-2">
<div className="w-1.5 h-1.5 bg-green-500 rounded-full flex-shrink-0" />
<span className="text-xs text-green-700 dark:text-green-400">{success}</span>
</div>
</div>
<p className="text-center text-neutral-600 dark:text-neutral-400 text-sm">
Redirection vers votre profil...
</p>
</>
)}
{error && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg dark:bg-red-500/10 dark:border-red-500/20">
<div className="flex items-center space-x-2">
<div className="w-1.5 h-1.5 bg-red-500 rounded-full flex-shrink-0" />
<span className="text-xs text-red-700 dark:text-red-400">{error}</span>
</div>
</div>
)}
</Card>
</div>
);
};
export default ConfirmEmailChangePage;
registerPage({ slug: 'confirm-email-change', title: 'Confirmation du courriel', Component: ConfirmEmailChangePage });
+86 -8
View File
@@ -20,12 +20,48 @@ const ProfilePage = ({ user: initialUser }) => {
const [uploadingImage, setUploadingImage] = useState(false);
const [formData, setFormData] = useState({ name: initialUser?.name || '' });
const [emailFormOpen, setEmailFormOpen] = useState(false);
const [emailFormData, setEmailFormData] = useState({ newEmail: '', password: '' });
const [emailLoading, setEmailLoading] = useState(false);
const [pendingEmailMessage, setPendingEmailMessage] = useState('');
useEffect(() => {
if (initialUser) setFormData({ name: initialUser.name || '' });
}, [initialUser]);
const hasChanges = formData.name !== user?.name;
const handleEmailSubmit = async (e) => {
e.preventDefault();
if (!emailFormData.newEmail.trim()) {
toast.error('Le nouveau courriel est requis');
return;
}
if (!emailFormData.password) {
toast.error('Le mot de passe est requis');
return;
}
setEmailLoading(true);
try {
const response = await fetch('/zen/api/users/profile/email', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ newEmail: emailFormData.newEmail.trim(), password: emailFormData.password }),
});
const data = await response.json();
if (!response.ok || !data.success) throw new Error(data.error || data.message || 'Échec de la demande de changement de courriel');
toast.success(data.message);
setPendingEmailMessage(data.message);
setEmailFormOpen(false);
setEmailFormData({ newEmail: '', password: '' });
} catch (error) {
toast.error(error.message || 'Échec de la demande de changement de courriel');
} finally {
setEmailLoading(false);
}
};
const handleSubmit = async (e) => {
e.preventDefault();
if (!formData.name.trim()) {
@@ -145,14 +181,56 @@ const ProfilePage = ({ user: initialUser }) => {
required
disabled={loading}
/>
<Input
label="Courriel"
type="email"
value={user?.email || ''}
disabled
readOnly
description="L'email ne peut pas être modifié"
/>
<div className="flex flex-col gap-1">
<Input
label="Courriel"
type="email"
value={user?.email || ''}
disabled
readOnly
/>
{pendingEmailMessage ? (
<p className="text-xs text-blue-600 dark:text-blue-400">{pendingEmailMessage}</p>
) : (
<button
type="button"
onClick={() => { setEmailFormOpen(prev => !prev); setPendingEmailMessage(''); setEmailFormData({ newEmail: '', password: '' }); }}
className="text-xs text-neutral-500 hover:text-neutral-800 dark:hover:text-neutral-200 underline self-start transition-colors"
>
{emailFormOpen ? 'Annuler' : 'Modifier le courriel'}
</button>
)}
{emailFormOpen && (
<form onSubmit={handleEmailSubmit} className="flex flex-col gap-3 mt-2 p-3 rounded-lg border border-neutral-200 dark:border-neutral-700 bg-neutral-50 dark:bg-neutral-800/50">
<Input
label="Nouveau courriel"
type="email"
value={emailFormData.newEmail}
onChange={(value) => setEmailFormData(prev => ({ ...prev, newEmail: value }))}
placeholder="nouvelle@adresse.com"
required
disabled={emailLoading}
/>
<Input
label="Mot de passe actuel"
type="password"
value={emailFormData.password}
onChange={(value) => setEmailFormData(prev => ({ ...prev, password: value }))}
placeholder="Votre mot de passe"
required
disabled={emailLoading}
/>
<div className="flex gap-2 justify-end">
<Button type="button" variant="secondary" onClick={() => { setEmailFormOpen(false); setEmailFormData({ newEmail: '', password: '' }); }} disabled={emailLoading}>
Annuler
</Button>
<Button type="submit" variant="primary" loading={emailLoading} disabled={emailLoading}>
Envoyer la confirmation
</Button>
</div>
</form>
)}
</div>
<Input
label="Compte créé"
type="text"
+4 -3
View File
@@ -8,7 +8,7 @@ import { useToast } from '@zen/core/toast';
import AdminHeader from '../components/AdminHeader.js';
import UserEditModal from '../components/UserEditModal.client.js';
const UsersPageClient = () => {
const UsersPageClient = ({ currentUserId }) => {
const toast = useToast();
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
@@ -164,6 +164,7 @@ const UsersPageClient = () => {
<UserEditModal
userId={editingUserId}
currentUserId={currentUserId}
isOpen={!!editingUserId}
onClose={() => setEditingUserId(null)}
onSaved={fetchUsers}
@@ -172,10 +173,10 @@ const UsersPageClient = () => {
);
};
const UsersPage = () => (
const UsersPage = ({ user }) => (
<div className="flex flex-col gap-4 sm:gap-6 lg:gap-8">
<AdminHeader title="Utilisateurs" description="Gérez les comptes utilisateurs" />
<UsersPageClient />
<UsersPageClient currentUserId={user?.id} />
</div>
);