feat(admin): add password management to user edit modal and profile page

- add new password field in UserEditModal with optional admin-set password on save
- add send password reset link button with loading state in UserEditModal
- add password change section with strength indicator in ProfilePage
- expose sendPasswordResetEmail utility in auth api
This commit is contained in:
2026-04-24 15:45:56 -04:00
parent 661f6c0783
commit c844bc5e86
3 changed files with 275 additions and 5 deletions
@@ -12,8 +12,9 @@ const UserEditModal = ({ userId, currentUserId, isOpen, onClose, onSaved }) => {
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [formData, setFormData] = useState({ name: '', email: '', currentPassword: '' });
const [formData, setFormData] = useState({ name: '', email: '', currentPassword: '', newPassword: '' });
const [errors, setErrors] = useState({});
const [sendingReset, setSendingReset] = useState(false);
const [allRoles, setAllRoles] = useState([]);
const [selectedRoleIds, setSelectedRoleIds] = useState([]);
@@ -70,6 +71,23 @@ const UserEditModal = ({ userId, currentUserId, isOpen, onClose, onSaved }) => {
if (errors[field]) setErrors(prev => ({ ...prev, [field]: null }));
};
const handleSendPasswordReset = async () => {
setSendingReset(true);
try {
const res = await fetch(`/zen/api/users/${userId}/send-password-reset`, {
method: 'POST',
credentials: 'include',
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || data.message || 'Impossible d\'envoyer le lien');
toast.success(data.message || 'Lien de réinitialisation envoyé');
} catch {
toast.error('Impossible d\'envoyer le lien de réinitialisation');
} finally {
setSendingReset(false);
}
};
const emailChanged = userData && formData.email !== userData.email;
const validate = () => {
@@ -156,6 +174,23 @@ const UserEditModal = ({ userId, currentUserId, isOpen, onClose, onSaved }) => {
toast.success('Utilisateur mis à jour');
}
if (formData.newPassword) {
const pwdRes = await fetch(`/zen/api/users/${userId}/password`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ newPassword: formData.newPassword }),
});
const pwdData = await pwdRes.json();
if (!pwdRes.ok) {
toast.error(pwdData.error || pwdData.message || 'Impossible de changer le mot de passe');
onSaved?.();
onClose();
return;
}
toast.success('Mot de passe mis à jour');
}
onSaved?.();
onClose();
} catch {
@@ -219,6 +254,27 @@ const UserEditModal = ({ userId, currentUserId, isOpen, onClose, onSaved }) => {
/>
)}
<div className="border-t border-neutral-200 dark:border-neutral-700 pt-4 flex flex-col gap-3">
<Input
label="Nouveau mot de passe (optionnel)"
type="password"
value={formData.newPassword}
onChange={(value) => handleInputChange('newPassword', value)}
placeholder="Laisser vide pour ne pas modifier"
description="Définir un nouveau mot de passe pour cet utilisateur"
/>
<div>
<button
type="button"
onClick={handleSendPasswordReset}
disabled={sendingReset}
className="text-xs text-neutral-500 hover:text-neutral-800 dark:hover:text-neutral-200 underline transition-colors disabled:opacity-50"
>
{sendingReset ? 'Envoi en cours…' : 'Envoyer un lien de réinitialisation par courriel'}
</button>
</div>
</div>
<TagInput
label="Rôles attribués"
options={roleOptions}
+107 -1
View File
@@ -2,13 +2,14 @@
import { registerPage } from '../registry.js';
import { useState, useEffect, useRef } from 'react';
import { Card, Input, Button, TabNav, UserAvatar, Modal } from '@zen/core/shared/components';
import { Card, Input, Button, TabNav, UserAvatar, Modal, PasswordStrengthIndicator } from '@zen/core/shared/components';
import { useToast } from '@zen/core/toast';
import AdminHeader from '../components/AdminHeader.js';
const TABS = [
{ id: 'informations', label: 'Informations' },
{ id: 'photo', label: 'Photo de profil' },
{ id: 'securite', label: 'Sécurité' },
];
const ProfilePage = ({ user: initialUser }) => {
@@ -25,12 +26,49 @@ const ProfilePage = ({ user: initialUser }) => {
const [emailLoading, setEmailLoading] = useState(false);
const [pendingEmailMessage, setPendingEmailMessage] = useState('');
const [passwordForm, setPasswordForm] = useState({ currentPassword: '', newPassword: '', confirmPassword: '' });
const [passwordLoading, setPasswordLoading] = useState(false);
useEffect(() => {
if (initialUser) setFormData({ name: initialUser.name || '' });
}, [initialUser]);
const hasChanges = formData.name !== user?.name;
const validatePassword = (password) => {
if (!password || password.length < 8) return 'Le mot de passe doit contenir au moins 8 caractères';
if (password.length > 128) return 'Le mot de passe doit contenir 128 caractères ou moins';
if (!/[A-Z]/.test(password)) return 'Le mot de passe doit contenir au moins une majuscule';
if (!/[a-z]/.test(password)) return 'Le mot de passe doit contenir au moins une minuscule';
if (!/\d/.test(password)) return 'Le mot de passe doit contenir au moins un chiffre';
return null;
};
const handlePasswordSubmit = async (e) => {
e.preventDefault();
if (!passwordForm.currentPassword) { toast.error('Le mot de passe actuel est requis'); return; }
const pwdError = validatePassword(passwordForm.newPassword);
if (pwdError) { toast.error(pwdError); return; }
if (passwordForm.newPassword !== passwordForm.confirmPassword) { toast.error('Les mots de passe ne correspondent pas'); return; }
setPasswordLoading(true);
try {
const response = await fetch('/zen/api/users/profile/password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ currentPassword: passwordForm.currentPassword, newPassword: passwordForm.newPassword }),
});
const data = await response.json();
if (!response.ok || !data.success) throw new Error(data.error || data.message || 'Échec de la mise à jour du mot de passe');
toast.success(data.message);
setPasswordForm({ currentPassword: '', newPassword: '', confirmPassword: '' });
} catch (error) {
toast.error(error.message || 'Échec de la mise à jour du mot de passe');
} finally {
setPasswordLoading(false);
}
};
const handleEmailSubmit = async () => {
if (!emailFormData.newEmail.trim()) {
toast.error('Le nouveau courriel est requis');
@@ -215,6 +253,74 @@ const ProfilePage = ({ user: initialUser }) => {
</Card>
)}
{activeTab === 'securite' && (
<Card
title="Changer le mot de passe"
className="min-w-3/5"
footer={
<div className="flex items-center justify-end gap-3">
<Button
type="button"
variant="secondary"
onClick={() => setPasswordForm({ currentPassword: '', newPassword: '', confirmPassword: '' })}
disabled={passwordLoading}
>
Réinitialiser
</Button>
<Button
type="submit"
form="password-form"
variant="primary"
disabled={passwordLoading}
loading={passwordLoading}
>
Enregistrer le mot de passe
</Button>
</div>
}
>
<form id="password-form" onSubmit={handlePasswordSubmit} className="flex flex-col gap-4">
<Input
label="Mot de passe actuel"
type="password"
value={passwordForm.currentPassword}
onChange={(value) => setPasswordForm(prev => ({ ...prev, currentPassword: value }))}
placeholder="••••••••"
autoComplete="current-password"
required
disabled={passwordLoading}
/>
<div>
<Input
label="Nouveau mot de passe"
type="password"
value={passwordForm.newPassword}
onChange={(value) => setPasswordForm(prev => ({ ...prev, newPassword: value }))}
placeholder="••••••••"
autoComplete="new-password"
minLength="8"
maxLength="128"
required
disabled={passwordLoading}
/>
<PasswordStrengthIndicator password={passwordForm.newPassword} showRequirements={true} />
</div>
<Input
label="Confirmer le nouveau mot de passe"
type="password"
value={passwordForm.confirmPassword}
onChange={(value) => setPasswordForm(prev => ({ ...prev, confirmPassword: value }))}
placeholder="••••••••"
autoComplete="new-password"
minLength="8"
maxLength="128"
required
disabled={passwordLoading}
/>
</form>
</Card>
)}
{activeTab === 'photo' && (
<Card title="Photo de profil" className="min-w-3/5">
<p className="text-sm text-neutral-500 dark:text-neutral-400">