Files
core/src/features/auth/api.js
T
hykocx c959b16db5 refactor(api): add granular permission enforcement on admin routes
- add optional `permission` field to route definitions with type validation in `define.js`
- check `hasPermission()` in router after `requireAdmin()` and return 403 if denied
- document `permission` and `skipRateLimit` optional fields in api README
- load user permissions in `AdminPage.server.js` and pass them to client via `user` prop
- use `user.permissions` in `RolesPage` and `UsersPage` to conditionally render actions
- expose permission-gated API routes in `auth/api.js`
2026-04-25 09:21:07 -04:00

924 lines
36 KiB
JavaScript

/**
* 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 },
]);