/** * Auth Feature — API Routes * * User management endpoints (profile, admin CRUD). * Auth is enforced by the router before any handler is called — no manual * session validation is needed here. The validated session is injected via * the context argument: (request, params, { session }). */ import { query, create, updateById, findOne } from '@zen/core/database'; import { updateUser, requestPasswordReset } from './auth.js'; import { hashPassword, verifyPassword, generateId } from '../../core/users/password.js'; import { createEmailChangeToken, verifyEmailChangeToken, applyEmailChange, sendEmailChangeConfirmEmail, sendEmailChangeOldNotifyEmail, sendEmailChangeNewNotifyEmail, sendPasswordChangedEmail, sendPasswordResetEmail, sendInvitationEmail } from './email.js'; import { createAccountSetup } from '../../core/users/verifications.js'; import { listRoles, getRoleById, createRole, updateRole, deleteRole, getUserRoles, assignUserRole, revokeUserRole, deleteUserSessions, PERMISSIONS } 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'; const generateUserFilePath = (userId, category, filename) => `users/${userId}/${category}/${filename}`; import { fail, info } from '@zen/core/shared/logger'; import { defineApiRoutes, apiSuccess, apiError } from '@zen/core/api'; /** Maximum number of users returned per paginated request */ const MAX_PAGE_LIMIT = 100; /** * Extension → MIME type map derived from the validated file extension. * The client-supplied file.type is NEVER trusted. */ const EXTENSION_TO_MIME = { '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', '.gif': 'image/gif', '.webp': 'image/webp', }; /** * Log the raw error server-side and return an opaque fallback. * Never forward internal error details to the client. */ function logAndObscureError(error, fallback) { fail(`Internal handler error: ${error.message}`); return fallback; } // --------------------------------------------------------------------------- // GET /zen/api/users/:id (admin only) // --------------------------------------------------------------------------- async function handleGetUserById(_request, { id: userId }) { const result = await query( 'SELECT id, email, name, role, image, email_verified, created_at FROM zen_auth_users WHERE id = $1', [userId] ); if (result.rows.length === 0) { return apiError('Not Found', 'User not found'); } return apiSuccess({ user: result.rows[0] }); } // --------------------------------------------------------------------------- // PUT /zen/api/users/:id (admin only) // --------------------------------------------------------------------------- async function handleUpdateUserById(request, { id: userId }) { try { const body = await request.json(); const allowedFields = ['name', 'role', 'email_verified']; const updateData = { updated_at: new Date() }; for (const field of allowedFields) { if (body[field] !== undefined) { if (field === 'email_verified') { updateData[field] = Boolean(body[field]); } else if (field === 'role') { const role = String(body[field]).toLowerCase(); if (['admin', 'user'].includes(role)) { updateData[field] = role; } } else if (field === 'name' && body[field] != null) { updateData[field] = String(body[field]).trim() || null; } } } const updated = await updateById('zen_auth_users', userId, updateData); if (!updated) { return apiError('Not Found', 'User not found'); } const result = await query( 'SELECT id, email, name, role, image, email_verified, created_at FROM zen_auth_users WHERE id = $1', [userId] ); return apiSuccess({ success: true, user: result.rows[0], message: 'User updated successfully' }); } catch (error) { logAndObscureError(error, null); return apiError('Internal Server Error', 'Failed to update user'); } } // --------------------------------------------------------------------------- // GET /zen/api/users (admin only) // --------------------------------------------------------------------------- async function handleListUsers(request) { // Both page and limit are clamped server-side; client-supplied values // cannot force full-table scans or negative offsets. const url = new URL(request.url); const page = Math.max(1, parseInt(url.searchParams.get('page') || '1', 10) || 1); const limit = Math.min( Math.max(1, parseInt(url.searchParams.get('limit') || '10', 10) || 10), MAX_PAGE_LIMIT ); const offset = (page - 1) * limit; const sortBy = url.searchParams.get('sortBy') || 'created_at'; const sortOrder = url.searchParams.get('sortOrder') || 'desc'; // Whitelist allowed sort columns to prevent SQL injection via identifier injection. const allowedSortColumns = ['id', 'email', 'name', 'role', 'email_verified', 'created_at']; const sortColumn = allowedSortColumns.includes(sortBy) ? sortBy : 'created_at'; const order = sortOrder.toLowerCase() === 'asc' ? 'ASC' : 'DESC'; // Wrap the whitelisted column in double-quotes to enforce identifier boundaries. const quotedSortColumn = `"${sortColumn}"`; const result = await query( `SELECT u.id, u.email, u.name, u.role, u.image, u.email_verified, u.created_at, COALESCE( (SELECT json_agg(json_build_object('id', r.id, 'name', r.name, 'color', r.color) ORDER BY r.created_at ASC) FROM zen_auth_roles r JOIN zen_auth_user_roles ur ON ur.role_id = r.id WHERE ur.user_id = u.id), '[]'::json ) AS roles FROM zen_auth_users u ORDER BY u.${quotedSortColumn} ${order} LIMIT $1 OFFSET $2`, [limit, offset] ); const countResult = await query('SELECT COUNT(*) FROM zen_auth_users'); const total = parseInt(countResult.rows[0].count); return apiSuccess({ users: result.rows, pagination: { page, limit, total, totalPages: Math.ceil(total / limit) } }); } // --------------------------------------------------------------------------- // POST /zen/api/users/profile/email // --------------------------------------------------------------------------- const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; async function handleInitiateEmailChange(request, _params, { session }) { try { const body = await request.json(); const { newEmail, password } = body; if (!newEmail || !EMAIL_REGEX.test(newEmail) || newEmail.length > 254) { return apiError('Bad Request', 'Adresse courriel invalide'); } if (!password) { return apiError('Bad Request', 'Le mot de passe est requis'); } const normalizedEmail = newEmail.trim().toLowerCase(); if (normalizedEmail === session.user.email.toLowerCase()) { return apiError('Bad Request', 'Cette adresse courriel est déjà la vôtre'); } const existing = await findOne('zen_auth_users', { email: normalizedEmail }); if (existing) { return apiError('Conflict', 'Cette adresse courriel est déjà utilisée'); } 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 passwordValid = await verifyPassword(password, account.password); if (!passwordValid) { return apiError('Unauthorized', 'Mot de passe incorrect'); } const token = await createEmailChangeToken(session.user.id, normalizedEmail); const baseUrl = getPublicBaseUrl(); try { await sendEmailChangeConfirmEmail(normalizedEmail, token, baseUrl); } catch (emailError) { fail(`handleInitiateEmailChange: failed to send confirmation email: ${emailError.message}`); return apiError('Internal Server Error', 'Impossible d\'envoyer le courriel de confirmation'); } try { await sendEmailChangeOldNotifyEmail(session.user.email, normalizedEmail, 'pending'); } catch (emailError) { fail(`handleInitiateEmailChange: failed to send notification email: ${emailError.message}`); } return apiSuccess({ success: true, message: `Un courriel de confirmation a été envoyé à ${normalizedEmail}` }); } catch (error) { logAndObscureError(error, null); return apiError('Internal Server Error', 'Impossible d\'initier le changement de courriel'); } } // --------------------------------------------------------------------------- // GET /zen/api/users/email/confirm // --------------------------------------------------------------------------- async function handleConfirmEmailChange(request, _params, { session }) { try { const url = new URL(request.url); const token = url.searchParams.get('token'); if (!token) { return apiError('Bad Request', 'Jeton de confirmation manquant'); } const result = await verifyEmailChangeToken(token); if (!result) { return apiError('Bad Request', 'Lien de confirmation invalide ou expiré'); } const { userId, newEmail } = result; if (userId !== session.user.id) { return apiError('Forbidden', 'Ce lien ne vous appartient pas'); } const existing = await findOne('zen_auth_users', { email: newEmail }); if (existing) { return apiError('Conflict', 'Cette adresse courriel est déjà utilisée'); } await applyEmailChange(userId, newEmail); return apiSuccess({ success: true, message: 'Adresse courriel mise à jour avec succès' }); } catch (error) { logAndObscureError(error, null); return apiError('Internal Server Error', 'Impossible de confirmer le changement de courriel'); } } // --------------------------------------------------------------------------- // PUT /zen/api/users/:id/email (admin only) // --------------------------------------------------------------------------- async function handleAdminUpdateUserEmail(request, { id: userId }) { try { const body = await request.json(); const { newEmail } = body; if (!newEmail || !EMAIL_REGEX.test(newEmail) || newEmail.length > 254) { return apiError('Bad Request', 'Adresse courriel invalide'); } const normalizedEmail = newEmail.trim().toLowerCase(); const targetUser = await findOne('zen_auth_users', { id: userId }); if (!targetUser) { return apiError('Not Found', 'Utilisateur introuvable'); } if (normalizedEmail === targetUser.email.toLowerCase()) { return apiError('Bad Request', 'Cette adresse courriel est déjà celle de l\'utilisateur'); } const existing = await findOne('zen_auth_users', { email: normalizedEmail }); if (existing) { return apiError('Conflict', 'Cette adresse courriel est déjà utilisée'); } const oldEmail = targetUser.email; await applyEmailChange(userId, normalizedEmail); try { await sendEmailChangeOldNotifyEmail(oldEmail, normalizedEmail, 'changed'); } catch (emailError) { fail(`handleAdminUpdateUserEmail: failed to notify old email ${oldEmail}: ${emailError.message}`); } try { await sendEmailChangeNewNotifyEmail(normalizedEmail, oldEmail); } catch (emailError) { fail(`handleAdminUpdateUserEmail: failed to notify new email ${normalizedEmail}: ${emailError.message}`); } const updated = await query( 'SELECT id, email, name, role, image, email_verified, created_at FROM zen_auth_users WHERE id = $1', [userId] ); return apiSuccess({ success: true, user: updated.rows[0], message: 'Courriel mis à jour avec succès' }); } catch (error) { logAndObscureError(error, null); return apiError('Internal Server Error', 'Impossible de mettre à jour le courriel'); } } // --------------------------------------------------------------------------- // PUT /zen/api/users/profile // --------------------------------------------------------------------------- async function handleUpdateProfile(request, _params, { session }) { try { const body = await request.json(); const { name } = body; if (!name || !name.trim()) { return apiError('Bad Request', 'Name is required'); } const updatedUser = await updateUser(session.user.id, { name: name.trim() }); return apiSuccess({ success: true, user: { id: updatedUser.id, email: updatedUser.email, name: updatedUser.name, role: updatedUser.role, image: updatedUser.image, email_verified: updatedUser.email_verified, created_at: updatedUser.created_at }, message: 'Profile updated successfully' }); } catch (error) { logAndObscureError(error, null); return apiError('Internal Server Error', 'Failed to update profile'); } } // --------------------------------------------------------------------------- // POST /zen/api/users/profile/picture // --------------------------------------------------------------------------- async function handleUploadProfilePicture(request, _params, { session }) { try { const formData = await request.formData(); const file = formData.get('file'); if (!file) { return apiError('Bad Request', 'No file provided'); } // Read the buffer once — used for both content inspection and the upload. const buffer = Buffer.from(await file.arrayBuffer()); // Validate file — passes buffer for magic-byte / content-pattern inspection. const validation = validateUpload({ filename: file.name, size: file.size, allowedTypes: FILE_TYPE_PRESETS.IMAGES, maxSize: FILE_SIZE_LIMITS.AVATAR, buffer, }); if (!validation.valid) { return apiError('Bad Request', validation.errors.join(', ')); } // Check for an existing profile picture to replace later. const currentUser = await query( 'SELECT image FROM zen_auth_users WHERE id = $1', [session.user.id] ); const oldImageKey = currentUser.rows[0]?.image ?? null; // Generate storage path. const uniqueFilename = generateUniqueFilename(file.name, 'avatar'); const key = generateUserFilePath(session.user.id, 'profile', uniqueFilename); // Derive content-type from the validated extension — never from file.type, // which is fully attacker-controlled. const ext = getFileExtension(file.name).toLowerCase(); const contentType = EXTENSION_TO_MIME[ext] ?? 'application/octet-stream'; const uploadResult = await uploadImage({ key, body: buffer, contentType, metadata: { userId: session.user.id, originalName: file.name } }); if (!uploadResult.success) { return apiError('Internal Server Error', 'Failed to upload image'); } // Commit the DB write first. Only on success do we remove the old object — // an orphaned old object is preferable to a broken DB reference. let updatedUser; try { updatedUser = await updateUser(session.user.id, { image: key }); } catch (dbError) { // Roll back the newly uploaded object so storage stays clean. try { await deleteFile(key); } catch (rollbackError) { fail(`Rollback delete of newly uploaded object failed: ${rollbackError.message}`); } throw dbError; } if (oldImageKey) { try { await deleteFile(oldImageKey); info(`Deleted old profile picture: ${oldImageKey}`); } catch (deleteError) { // Non-fatal: log for operator cleanup; the DB reference is consistent. fail(`Failed to delete old profile picture (orphaned object): ${deleteError.message}`); } } return apiSuccess({ success: true, user: { id: updatedUser.id, email: updatedUser.email, name: updatedUser.name, role: updatedUser.role, image: updatedUser.image, email_verified: updatedUser.email_verified, created_at: updatedUser.created_at }, message: 'Profile picture uploaded successfully' }); } catch (error) { logAndObscureError(error, null); return apiError('Internal Server Error', 'Failed to upload profile picture'); } } // --------------------------------------------------------------------------- // DELETE /zen/api/users/profile/picture // --------------------------------------------------------------------------- async function handleDeleteProfilePicture(_request, _params, { session }) { try { const currentUser = await query( 'SELECT image FROM zen_auth_users WHERE id = $1', [session.user.id] ); if (currentUser.rows.length === 0) { return apiError('Not Found', 'User not found'); } const imageKey = currentUser.rows[0].image; if (!imageKey) { return apiError('Bad Request', 'No profile picture to delete'); } const updatedUser = await updateUser(session.user.id, { image: null }); try { await deleteFile(imageKey); info(`Deleted profile picture: ${imageKey}`); } catch (deleteError) { // Non-fatal: the DB is already updated; log for operator cleanup. fail(`Failed to delete profile picture from storage: ${deleteError.message}`); } return apiSuccess({ success: true, user: { id: updatedUser.id, email: updatedUser.email, name: updatedUser.name, role: updatedUser.role, image: updatedUser.image, email_verified: updatedUser.email_verified, created_at: updatedUser.created_at }, message: 'Profile picture deleted successfully' }); } catch (error) { logAndObscureError(error, null); return apiError('Internal Server Error', 'Failed to delete profile picture'); } } // --------------------------------------------------------------------------- // GET /zen/api/users/:id/roles (admin only) // --------------------------------------------------------------------------- async function handleGetUserRoles(_request, { id: userId }) { const roles = await getUserRoles(userId); return apiSuccess({ roles }); } // --------------------------------------------------------------------------- // POST /zen/api/users/:id/roles (admin only) // --------------------------------------------------------------------------- async function handleAssignUserRole(request, { id: userId }) { try { const body = await request.json(); const { roleId } = body; if (!roleId) return apiError('Bad Request', 'roleId is required'); const roleCheck = await query(`SELECT id FROM zen_auth_roles WHERE id = $1`, [roleId]); if (roleCheck.rows.length === 0) return apiError('Not Found', 'Role not found'); await assignUserRole(userId, roleId); return apiSuccess({ success: true }); } catch (error) { logAndObscureError(error, null); return apiError('Internal Server Error', 'Failed to assign role'); } } // --------------------------------------------------------------------------- // DELETE /zen/api/users/:id/roles/:roleId (admin only) // --------------------------------------------------------------------------- async function handleRevokeUserRole(_request, { id: userId, roleId }) { try { await revokeUserRole(userId, roleId); return apiSuccess({ success: true }); } catch (error) { logAndObscureError(error, null); return apiError('Internal Server Error', 'Failed to revoke role'); } } // --------------------------------------------------------------------------- // GET /zen/api/roles (admin only) // --------------------------------------------------------------------------- async function handleListRoles() { const roles = await listRoles(); return apiSuccess({ roles }); } // --------------------------------------------------------------------------- // POST /zen/api/roles (admin only) // --------------------------------------------------------------------------- async function handleCreateRole(request) { try { const body = await request.json(); const { name, description, color, permissionKeys } = body; if (!name || !String(name).trim()) { return apiError('Bad Request', 'Role name is required'); } const role = await createRole({ name: String(name).trim(), description: description ? String(description).trim() : null, color: color ? String(color) : '#6b7280' }); if (Array.isArray(permissionKeys) && permissionKeys.length > 0) { const updatedRole = await updateRole(role.id, { permissionKeys }); return apiSuccess({ role: updatedRole }); } return apiSuccess({ role: { ...role, permission_keys: [] } }); } catch (error) { if (error.message === 'Role name is required') return apiError('Bad Request', error.message); logAndObscureError(error, null); return apiError('Internal Server Error', 'Failed to create role'); } } // --------------------------------------------------------------------------- // GET /zen/api/roles/:id (admin only) // --------------------------------------------------------------------------- async function handleGetRole(_request, { id: roleId }) { const role = await getRoleById(roleId); if (!role) return apiError('Not Found', 'Role not found'); return apiSuccess({ role }); } // --------------------------------------------------------------------------- // PUT /zen/api/roles/:id (admin only) // --------------------------------------------------------------------------- async function handleUpdateRole(request, { id: roleId }) { try { const body = await request.json(); const { name, description, color, permissionKeys } = body; const role = await updateRole(roleId, { ...(name !== undefined && { name: String(name).trim() }), ...(description !== undefined && { description: description ? String(description).trim() : null }), ...(color !== undefined && { color: String(color) }), ...(permissionKeys !== undefined && { permissionKeys: Array.isArray(permissionKeys) ? permissionKeys : [] }) }); return apiSuccess({ role }); } catch (error) { if (error.message === 'Role not found') return apiError('Not Found', error.message); if (error.message === 'Role name cannot be empty') return apiError('Bad Request', error.message); logAndObscureError(error, null); return apiError('Internal Server Error', 'Failed to update role'); } } // --------------------------------------------------------------------------- // DELETE /zen/api/roles/:id (admin only) // --------------------------------------------------------------------------- async function handleDeleteRole(_request, { id: roleId }) { try { await deleteRole(roleId); return apiSuccess({ success: true }); } catch (error) { if (error.message === 'Cannot delete a system role') return apiError('Bad Request', error.message); if (error.message === 'Role not found') return apiError('Not Found', error.message); logAndObscureError(error, null); return apiError('Internal Server Error', 'Failed to delete role'); } } // --------------------------------------------------------------------------- // GET /zen/api/users/profile/sessions (user — list own sessions) // --------------------------------------------------------------------------- function parseUserAgent(ua) { if (!ua) return { browser: 'Navigateur inconnu', os: 'Système inconnu', device: 'desktop' }; const device = /Mobile|Android|iPhone|iPad/i.test(ua) ? 'mobile' : 'desktop'; let os = 'Système inconnu'; if (/Windows/i.test(ua)) os = 'Windows'; else if (/Android/i.test(ua)) os = 'Android'; else if (/iPhone|iPad/i.test(ua)) os = 'iOS'; else if (/Mac OS X/i.test(ua)) os = 'macOS'; else if (/Linux/i.test(ua)) os = 'Linux'; let browser = 'Navigateur inconnu'; if (/Edg\//i.test(ua)) browser = 'Edge'; else if (/Chrome\//i.test(ua)) browser = 'Chrome'; else if (/Firefox\//i.test(ua)) browser = 'Firefox'; else if (/Safari\//i.test(ua)) browser = 'Safari'; return { browser, os, device }; } async function handleListSessions(_request, _params, { session }) { try { const result = await query( 'SELECT id, ip_address, user_agent, created_at, expires_at FROM zen_auth_sessions WHERE user_id = $1 ORDER BY created_at DESC', [session.user.id] ); const sessions = result.rows.map(s => ({ id: s.id, ip_address: s.ip_address, created_at: s.created_at, expires_at: s.expires_at, ...parseUserAgent(s.user_agent), })); return apiSuccess({ sessions, currentSessionId: session.session.id }); } catch (error) { logAndObscureError(error, null); return apiError('Internal Server Error', 'Impossible de récupérer les sessions'); } } // --------------------------------------------------------------------------- // DELETE /zen/api/users/profile/sessions (user — revoke all own sessions) // --------------------------------------------------------------------------- async function handleDeleteAllSessions(_request, _params, { session }) { try { await deleteUserSessions(session.user.id); return apiSuccess({ success: true }); } catch (error) { logAndObscureError(error, null); return apiError('Internal Server Error', 'Impossible de révoquer les sessions'); } } // --------------------------------------------------------------------------- // DELETE /zen/api/users/profile/sessions/:sessionId (user — revoke one session) // --------------------------------------------------------------------------- async function handleDeleteSession(_request, { sessionId }, { session }) { try { const result = await query( 'DELETE FROM zen_auth_sessions WHERE id = $1 AND user_id = $2 RETURNING id', [sessionId, session.user.id] ); if (result.rows.length === 0) { return apiError('Not Found', 'Session introuvable'); } return apiSuccess({ success: true, isCurrent: sessionId === session.session.id }); } catch (error) { logAndObscureError(error, null); return apiError('Internal Server Error', 'Impossible de révoquer la session'); } } // --------------------------------------------------------------------------- // 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'); } } // --------------------------------------------------------------------------- // POST /zen/api/users (admin only) // --------------------------------------------------------------------------- async function handleAdminCreateUser(request) { try { const body = await request.json(); const { name, email, password, roleIds } = body; if (!name || typeof name !== 'string' || name.trim().length === 0) { return apiError('Bad Request', 'Le nom est requis'); } if (name.trim().length > 100) { return apiError('Bad Request', 'Le nom doit contenir 100 caractères ou moins'); } if (!email || !EMAIL_REGEX.test(email) || email.length > 254) { return apiError('Bad Request', 'Adresse courriel invalide'); } const normalizedEmail = email.trim().toLowerCase(); const existing = await findOne('zen_auth_users', { email: normalizedEmail }); if (existing) { return apiError('Conflict', 'Cette adresse courriel est déjà utilisée'); } const userId = generateId(); const hasPassword = typeof password === 'string' && password.length > 0; const user = await create('zen_auth_users', { id: userId, email: normalizedEmail, name: name.trim(), email_verified: hasPassword, image: null, role: 'user', updated_at: new Date() }); if (hasPassword) { const hashedPassword = await hashPassword(password); await create('zen_auth_accounts', { id: generateId(), account_id: normalizedEmail, provider_id: 'credential', user_id: user.id, password: hashedPassword, updated_at: new Date() }); } else { const setup = await createAccountSetup(normalizedEmail); const baseUrl = getPublicBaseUrl(); try { await sendInvitationEmail(normalizedEmail, setup.token, baseUrl); } catch (emailError) { fail(`handleAdminCreateUser: failed to send invitation email to ${normalizedEmail}: ${emailError.message}`); } } if (Array.isArray(roleIds) && roleIds.length > 0) { for (const roleId of roleIds) { if (typeof roleId === 'string' && roleId.length > 0) { try { await assignUserRole(user.id, roleId); } catch (err) { fail(`handleAdminCreateUser: failed to assign role ${roleId} to user ${user.id}: ${err.message}`); } } } } return apiSuccess({ user, invited: !hasPassword }); } catch (error) { logAndObscureError(error, null); return apiError('Internal Server Error', 'Impossible de créer l\'utilisateur'); } } // --------------------------------------------------------------------------- // Route definitions // --------------------------------------------------------------------------- // // Order matters: specific paths (/users/profile) must come before // parameterised paths (/users/:id) so they match first. export const routes = defineApiRoutes([ { path: '/users', method: 'GET', handler: handleListUsers, auth: 'admin', permission: PERMISSIONS.USERS_VIEW }, { path: '/users', method: 'POST', handler: handleAdminCreateUser, auth: 'admin', permission: PERMISSIONS.USERS_EDIT }, { 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/profile/sessions', method: 'GET', handler: handleListSessions, auth: 'user' }, { path: '/users/profile/sessions', method: 'DELETE', handler: handleDeleteAllSessions, auth: 'user' }, { path: '/users/profile/sessions/:sessionId', method: 'DELETE', handler: handleDeleteSession, auth: 'user' }, { path: '/users/email/confirm', method: 'GET', handler: handleConfirmEmailChange, auth: 'user' }, { path: '/users/:id/roles', method: 'GET', handler: handleGetUserRoles, auth: 'admin', permission: PERMISSIONS.USERS_VIEW }, { path: '/users/:id/roles', method: 'POST', handler: handleAssignUserRole, auth: 'admin', permission: PERMISSIONS.USERS_EDIT }, { path: '/users/:id/roles/:roleId', method: 'DELETE', handler: handleRevokeUserRole, auth: 'admin', permission: PERMISSIONS.USERS_EDIT }, { path: '/users/:id', method: 'GET', handler: handleGetUserById, auth: 'admin', permission: PERMISSIONS.USERS_VIEW }, { path: '/users/:id', method: 'PUT', handler: handleUpdateUserById, auth: 'admin', permission: PERMISSIONS.USERS_EDIT }, { path: '/users/:id/email', method: 'PUT', handler: handleAdminUpdateUserEmail, auth: 'admin', permission: PERMISSIONS.USERS_EDIT }, { path: '/users/:id/password', method: 'PUT', handler: handleAdminSetUserPassword, auth: 'admin', permission: PERMISSIONS.USERS_EDIT }, { path: '/users/:id/send-password-reset', method: 'POST', handler: handleAdminSendPasswordReset, auth: 'admin', permission: PERMISSIONS.USERS_EDIT }, { path: '/roles', method: 'GET', handler: handleListRoles, auth: 'admin', permission: PERMISSIONS.ROLES_VIEW }, { path: '/roles', method: 'POST', handler: handleCreateRole, auth: 'admin', permission: PERMISSIONS.ROLES_MANAGE }, { path: '/roles/:id', method: 'GET', handler: handleGetRole, auth: 'admin', permission: PERMISSIONS.ROLES_VIEW }, { path: '/roles/:id', method: 'PUT', handler: handleUpdateRole, auth: 'admin', permission: PERMISSIONS.ROLES_MANAGE }, { path: '/roles/:id', method: 'DELETE', handler: handleDeleteRole, auth: 'admin', permission: PERMISSIONS.ROLES_MANAGE }, ]);