c959b16db5
- 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`
924 lines
36 KiB
JavaScript
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 },
|
|
]);
|