diff --git a/src/features/admin/components/UserEditModal.client.js b/src/features/admin/components/UserEditModal.client.js index c272496..b8bc001 100644 --- a/src/features/admin/components/UserEditModal.client.js +++ b/src/features/admin/components/UserEditModal.client.js @@ -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 }) => { /> )} +
diff --git a/src/features/auth/api.js b/src/features/auth/api.js index d0cadc3..f87fe4c 100644 --- a/src/features/auth/api.js +++ b/src/features/auth/api.js @@ -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' },