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:
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
+111
-3
@@ -8,9 +8,9 @@
|
||||
*/
|
||||
|
||||
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 { updateUser, requestPasswordReset } from './auth.js';
|
||||
import { hashPassword, verifyPassword } from '../../core/users/password.js';
|
||||
import { createEmailChangeToken, verifyEmailChangeToken, applyEmailChange, sendEmailChangeConfirmEmail, sendEmailChangeOldNotifyEmail, sendEmailChangeNewNotifyEmail, sendPasswordChangedEmail, sendPasswordResetEmail } 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';
|
||||
@@ -627,6 +627,111 @@ async function handleDeleteRole(_request, { id: roleId }) {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /zen/api/users/profile/password (user — change own password)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const PASSWORD_REGEX_UPPER = /[A-Z]/;
|
||||
const PASSWORD_REGEX_LOWER = /[a-z]/;
|
||||
const PASSWORD_REGEX_DIGIT = /\d/;
|
||||
|
||||
function validateNewPassword(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 (!PASSWORD_REGEX_UPPER.test(password)) return 'Le mot de passe doit contenir au moins une majuscule';
|
||||
if (!PASSWORD_REGEX_LOWER.test(password)) return 'Le mot de passe doit contenir au moins une minuscule';
|
||||
if (!PASSWORD_REGEX_DIGIT.test(password)) return 'Le mot de passe doit contenir au moins un chiffre';
|
||||
return null;
|
||||
}
|
||||
|
||||
async function handleChangeOwnPassword(request, _params, { session }) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { currentPassword, newPassword } = body;
|
||||
|
||||
if (!currentPassword) return apiError('Bad Request', 'Le mot de passe actuel est requis');
|
||||
|
||||
const passwordError = validateNewPassword(newPassword);
|
||||
if (passwordError) return apiError('Bad Request', passwordError);
|
||||
|
||||
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 valid = await verifyPassword(currentPassword, account.password);
|
||||
if (!valid) return apiError('Unauthorized', 'Mot de passe actuel incorrect');
|
||||
|
||||
const hashed = await hashPassword(newPassword);
|
||||
await updateById('zen_auth_accounts', account.id, { password: hashed, updated_at: new Date() });
|
||||
|
||||
try {
|
||||
await sendPasswordChangedEmail(session.user.email);
|
||||
} catch (emailError) {
|
||||
fail(`handleChangeOwnPassword: failed to send notification: ${emailError.message}`);
|
||||
}
|
||||
|
||||
return apiSuccess({ success: true, message: 'Mot de passe mis à jour avec succès' });
|
||||
} catch (error) {
|
||||
logAndObscureError(error, null);
|
||||
return apiError('Internal Server Error', 'Impossible de mettre à jour le mot de passe');
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PUT /zen/api/users/:id/password (admin — set any user's password)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function handleAdminSetUserPassword(request, { id: userId }) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { newPassword } = body;
|
||||
|
||||
const passwordError = validateNewPassword(newPassword);
|
||||
if (passwordError) return apiError('Bad Request', passwordError);
|
||||
|
||||
const targetUser = await findOne('zen_auth_users', { id: userId });
|
||||
if (!targetUser) return apiError('Not Found', 'Utilisateur introuvable');
|
||||
|
||||
const account = await findOne('zen_auth_accounts', { user_id: userId, provider_id: 'credential' });
|
||||
if (!account) return apiError('Not Found', 'Compte introuvable');
|
||||
|
||||
const hashed = await hashPassword(newPassword);
|
||||
await updateById('zen_auth_accounts', account.id, { password: hashed, updated_at: new Date() });
|
||||
|
||||
try {
|
||||
await sendPasswordChangedEmail(targetUser.email);
|
||||
} catch (emailError) {
|
||||
fail(`handleAdminSetUserPassword: failed to send notification: ${emailError.message}`);
|
||||
}
|
||||
|
||||
return apiSuccess({ success: true, message: 'Mot de passe mis à jour' });
|
||||
} catch (error) {
|
||||
logAndObscureError(error, null);
|
||||
return apiError('Internal Server Error', 'Impossible de mettre à jour le mot de passe');
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /zen/api/users/:id/send-password-reset (admin — send reset link)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function handleAdminSendPasswordReset(_request, { id: userId }) {
|
||||
try {
|
||||
const targetUser = await findOne('zen_auth_users', { id: userId });
|
||||
if (!targetUser) return apiError('Not Found', 'Utilisateur introuvable');
|
||||
|
||||
const result = await requestPasswordReset(targetUser.email);
|
||||
|
||||
if (result.token) {
|
||||
await sendPasswordResetEmail(targetUser.email, result.token, getPublicBaseUrl());
|
||||
}
|
||||
|
||||
return apiSuccess({ success: true, message: 'Lien de réinitialisation envoyé' });
|
||||
} catch (error) {
|
||||
logAndObscureError(error, null);
|
||||
return apiError('Internal Server Error', 'Impossible d\'envoyer le lien de réinitialisation');
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Route definitions
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -638,6 +743,7 @@ export const routes = defineApiRoutes([
|
||||
{ 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/password', method: 'POST', handler: handleChangeOwnPassword, 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' },
|
||||
@@ -647,6 +753,8 @@ export const routes = defineApiRoutes([
|
||||
{ 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: '/users/:id/password', method: 'PUT', handler: handleAdminSetUserPassword, auth: 'admin' },
|
||||
{ path: '/users/:id/send-password-reset', method: 'POST', handler: handleAdminSendPasswordReset, 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' },
|
||||
|
||||
Reference in New Issue
Block a user