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
+111 -3
View File
@@ -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' },