feat(users): refactor users system

This commit is contained in:
2026-04-19 16:42:33 -04:00
parent af8da2aa86
commit f08376d979
24 changed files with 1531 additions and 669 deletions
+6
View File
@@ -83,6 +83,12 @@
"./features/provider": {
"import": "./dist/features/provider/index.js"
},
"./users": {
"import": "./dist/core/users/index.js"
},
"./users/constants": {
"import": "./dist/core/users/constants.js"
},
"./api": {
"import": "./dist/core/api/index.js"
},
+4 -2
View File
@@ -22,6 +22,7 @@ import { cookies } from 'next/headers';
import { getSessionCookieName } from '@zen/core/shared/config';
import { checkRateLimit, getIpFromRequest, formatRetryAfter } from '@zen/core/shared/rate-limit';
import { fail } from '@zen/core/shared/logger';
import { hasPermission, PERMISSIONS } from '@zen/core/users';
import { getCoreRoutes } from './core-routes.js';
import { getFeatureRoutes, getSessionResolver } from './runtime.js';
import { apiError } from './respond.js';
@@ -56,13 +57,14 @@ export async function requireAuth() {
}
/**
* Exige une session admin valide. Lève une erreur si non authentifié ou non admin.
* Exige une session avec la permission admin.access.
* @returns {Promise<Object>} session
*/
export async function requireAdmin() {
const session = await requireAuth();
if (session.user.role !== 'admin') {
const allowed = await hasPermission(session.user.id, PERMISSIONS.ADMIN_ACCESS);
if (!allowed) {
throw new Error('Admin access required');
}
+231
View File
@@ -0,0 +1,231 @@
import { query, create, findOne, updateById, count } from '@zen/core/database';
import { hashPassword, verifyPassword, generateId } from './password.js';
import { createSession } from './session.js';
import { fail } from '@zen/core/shared/logger';
import { createEmailVerification, createPasswordReset, verifyResetToken, deleteResetToken } from './verifications.js';
async function register(userData, { onEmailVerification } = {}) {
const { email, password, name } = userData;
if (!email || !password || !name) {
throw new Error('L\'e-mail, le mot de passe et le nom sont requis');
}
if (email.length > 254) {
throw new Error('L\'e-mail doit contenir 254 caractères ou moins');
}
if (password.length < 8) {
throw new Error('Le mot de passe doit contenir au moins 8 caractères');
}
if (password.length > 128) {
throw new Error('Le mot de passe doit contenir 128 caractères ou moins');
}
const hasUppercase = /[A-Z]/.test(password);
const hasLowercase = /[a-z]/.test(password);
const hasNumber = /\d/.test(password);
if (!hasUppercase || !hasLowercase || !hasNumber) {
throw new Error('Le mot de passe doit contenir au moins une majuscule, une minuscule et un chiffre');
}
if (name.length > 100) {
throw new Error('Le nom doit contenir 100 caractères ou moins');
}
if (name.trim().length === 0) {
throw new Error('Le nom ne peut pas être vide');
}
const existingUser = await findOne('zen_auth_users', { email });
if (existingUser) {
throw new Error('Un utilisateur avec cet e-mail existe déjà');
}
const userCount = await count('zen_auth_users');
const role = userCount === 0 ? 'admin' : 'user';
const hashedPassword = await hashPassword(password);
const userId = generateId();
const user = await create('zen_auth_users', {
id: userId,
email,
name,
email_verified: false,
image: null,
role,
updated_at: new Date()
});
const accountId = generateId();
await create('zen_auth_accounts', {
id: accountId,
account_id: email,
provider_id: 'credential',
user_id: user.id,
password: hashedPassword,
updated_at: new Date()
});
// Assign admin role to first user via the new roles system
if (role === 'admin') {
try {
const adminRole = await query(`SELECT id FROM zen_auth_roles WHERE name = 'admin'`);
if (adminRole.rows.length > 0) {
await query(
`INSERT INTO zen_auth_user_roles (user_id, role_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
[user.id, adminRole.rows[0].id]
);
}
} catch (err) {
fail(`register: failed to assign admin role to first user: ${err.message}`);
}
}
const verification = await createEmailVerification(email);
if (onEmailVerification) {
try {
await onEmailVerification(email, verification.token);
} catch (err) {
fail(`register: failed to send verification email: ${err.message}`);
}
}
return { user, verificationToken: verification.token };
}
async function login(credentials, sessionOptions = {}) {
const { email, password } = credentials;
if (!email || !password) {
throw new Error('L\'e-mail et le mot de passe sont requis');
}
const user = await findOne('zen_auth_users', { email });
if (!user) {
throw new Error('E-mail ou mot de passe incorrect');
}
const account = await findOne('zen_auth_accounts', {
user_id: user.id,
provider_id: 'credential'
});
if (!account || !account.password) {
throw new Error('E-mail ou mot de passe incorrect');
}
const isValid = await verifyPassword(password, account.password);
if (!isValid) {
throw new Error('E-mail ou mot de passe incorrect');
}
const session = await createSession(user.id, sessionOptions);
return { user, session };
}
async function requestPasswordReset(email) {
if (!email) {
throw new Error('L\'e-mail est requis');
}
const user = await findOne('zen_auth_users', { email });
if (!user) {
return { success: true };
}
const reset = await createPasswordReset(email);
return { success: true, token: reset.token };
}
async function resetPassword(resetData, { onPasswordChanged } = {}) {
const { email, token, newPassword } = resetData;
if (!email || !token || !newPassword) {
throw new Error('L\'e-mail, le jeton et le nouveau mot de passe sont requis');
}
if (newPassword.length < 8) {
throw new Error('Le mot de passe doit contenir au moins 8 caractères');
}
if (newPassword.length > 128) {
throw new Error('Le mot de passe doit contenir 128 caractères ou moins');
}
const hasUppercase = /[A-Z]/.test(newPassword);
const hasLowercase = /[a-z]/.test(newPassword);
const hasNumber = /\d/.test(newPassword);
if (!hasUppercase || !hasLowercase || !hasNumber) {
throw new Error('Le mot de passe doit contenir au moins une majuscule, une minuscule et un chiffre');
}
const tokenValid = await verifyResetToken(email, token);
if (!tokenValid) {
throw new Error('Jeton de réinitialisation invalide ou expiré');
}
const user = await findOne('zen_auth_users', { email });
if (!user) {
throw new Error('Jeton de réinitialisation invalide');
}
const account = await findOne('zen_auth_accounts', {
user_id: user.id,
provider_id: 'credential'
});
if (!account) {
throw new Error('Compte introuvable');
}
const hashedPassword = await hashPassword(newPassword);
await updateById('zen_auth_accounts', account.id, {
password: hashedPassword,
updated_at: new Date()
});
await deleteResetToken(email);
if (onPasswordChanged) {
try {
await onPasswordChanged(email);
} catch (error) {
fail(`Auth: failed to send password changed email to ${email}: ${error.message}`);
}
}
return { success: true };
}
async function verifyUserEmail(userId) {
return await updateById('zen_auth_users', userId, {
email_verified: true,
updated_at: new Date()
});
}
async function updateUser(userId, updateData) {
const allowedFields = ['name', 'image', 'language'];
const filteredData = {};
for (const field of allowedFields) {
if (updateData[field] !== undefined) {
filteredData[field] = updateData[field];
}
}
filteredData.updated_at = new Date();
return await updateById('zen_auth_users', userId, filteredData);
}
export { register, login, requestPasswordReset, resetPassword, verifyUserEmail, updateUser };
+54
View File
@@ -0,0 +1,54 @@
/**
* Static permission definitions — single source of truth.
* No server-side imports: safe to use in both client and server code.
*/
export const PERMISSIONS = {
ADMIN_ACCESS: 'admin.access',
CONTENT_VIEW: 'content.view',
CONTENT_CREATE: 'content.create',
CONTENT_EDIT: 'content.edit',
CONTENT_DELETE: 'content.delete',
CONTENT_PUBLISH: 'content.publish',
MEDIA_VIEW: 'media.view',
MEDIA_UPLOAD: 'media.upload',
MEDIA_DELETE: 'media.delete',
USERS_VIEW: 'users.view',
USERS_EDIT: 'users.edit',
USERS_DELETE: 'users.delete',
ROLES_VIEW: 'roles.view',
ROLES_MANAGE: 'roles.manage',
SETTINGS_VIEW: 'settings.view',
SETTINGS_MANAGE: 'settings.manage',
};
export const PERMISSION_DEFINITIONS = [
{ key: 'admin.access', name: 'Accès au panneau admin', group_name: 'Administration' },
{ key: 'content.view', name: 'Voir le contenu', group_name: 'Contenu' },
{ key: 'content.create', name: 'Créer du contenu', group_name: 'Contenu' },
{ key: 'content.edit', name: 'Modifier le contenu', group_name: 'Contenu' },
{ key: 'content.delete', name: 'Supprimer le contenu', group_name: 'Contenu' },
{ key: 'content.publish', name: 'Publier le contenu', group_name: 'Contenu' },
{ key: 'media.view', name: 'Voir les médias', group_name: 'Médias' },
{ key: 'media.upload', name: 'Téléverser des médias', group_name: 'Médias' },
{ key: 'media.delete', name: 'Supprimer des médias', group_name: 'Médias' },
{ key: 'users.view', name: 'Voir les utilisateurs', group_name: 'Utilisateurs' },
{ key: 'users.edit', name: 'Modifier les utilisateurs', group_name: 'Utilisateurs' },
{ key: 'users.delete', name: 'Supprimer les utilisateurs', group_name: 'Utilisateurs' },
{ key: 'roles.view', name: 'Voir les rôles', group_name: 'Rôles' },
{ key: 'roles.manage', name: 'Gérer les rôles', group_name: 'Rôles' },
{ key: 'settings.view', name: 'Voir les paramètres', group_name: 'Paramètres' },
{ key: 'settings.manage', name: 'Gérer les paramètres', group_name: 'Paramètres' },
];
/**
* Returns permissions grouped by group_name.
* @returns {Object.<string, Array>}
*/
export function getPermissionGroups() {
return PERMISSION_DEFINITIONS.reduce((acc, perm) => {
if (!acc[perm.group_name]) acc[perm.group_name] = [];
acc[perm.group_name].push(perm);
return acc;
}, {});
}
+157
View File
@@ -0,0 +1,157 @@
import { query, tableExists } from '@zen/core/database';
import { generateId } from './password.js';
import { done, warn } from '@zen/core/shared/logger';
import { PERMISSION_DEFINITIONS } from './constants.js';
const USER_ROLE_PERMISSIONS = ['content.view', 'media.view'];
const ROLE_TABLES = [
{
name: 'zen_auth_roles',
sql: `
CREATE TABLE zen_auth_roles (
id text NOT NULL PRIMARY KEY,
name text NOT NULL UNIQUE,
description text,
color text DEFAULT '#6b7280',
is_system boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP
)
`
},
{
name: 'zen_auth_permissions',
sql: `
CREATE TABLE zen_auth_permissions (
key text NOT NULL PRIMARY KEY,
name text NOT NULL,
description text,
group_name text NOT NULL
)
`
},
{
name: 'zen_auth_role_permissions',
sql: `
CREATE TABLE zen_auth_role_permissions (
role_id text NOT NULL REFERENCES zen_auth_roles(id) ON DELETE CASCADE,
permission_key text NOT NULL REFERENCES zen_auth_permissions(key) ON DELETE CASCADE,
PRIMARY KEY (role_id, permission_key)
)
`
},
{
name: 'zen_auth_user_roles',
sql: `
CREATE TABLE zen_auth_user_roles (
user_id text NOT NULL REFERENCES zen_auth_users(id) ON DELETE CASCADE,
role_id text NOT NULL REFERENCES zen_auth_roles(id) ON DELETE CASCADE,
PRIMARY KEY (user_id, role_id)
)
`
}
];
async function dropRoleCheckConstraint() {
await query(`
DO $$ DECLARE cname text;
BEGIN
SELECT conname INTO cname FROM pg_constraint
WHERE conrelid = 'zen_auth_users'::regclass AND contype = 'c' AND conname LIKE '%role%';
IF cname IS NOT NULL THEN
EXECUTE 'ALTER TABLE zen_auth_users DROP CONSTRAINT ' || quote_ident(cname);
END IF;
END$$;
`);
}
async function seedDefaultRolesAndPermissions() {
// Permissions
for (const perm of PERMISSION_DEFINITIONS) {
await query(
`INSERT INTO zen_auth_permissions (key, name, group_name) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING`,
[perm.key, perm.name, perm.group_name]
);
}
// Admin role
const adminRoleId = generateId();
await query(
`INSERT INTO zen_auth_roles (id, name, description, color, is_system) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (name) DO NOTHING`,
[adminRoleId, 'admin', 'Accès complet à toutes les fonctionnalités', '#ef4444', true]
);
const adminRole = await query(`SELECT id FROM zen_auth_roles WHERE name = 'admin'`);
const adminId = adminRole.rows[0].id;
for (const perm of PERMISSION_DEFINITIONS) {
await query(
`INSERT INTO zen_auth_role_permissions (role_id, permission_key) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
[adminId, perm.key]
);
}
// User role
const userRoleId = generateId();
await query(
`INSERT INTO zen_auth_roles (id, name, description, color, is_system) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (name) DO NOTHING`,
[userRoleId, 'user', 'Accès de base en lecture', '#6b7280', true]
);
const userRole = await query(`SELECT id FROM zen_auth_roles WHERE name = 'user'`);
const userId = userRole.rows[0].id;
for (const permKey of USER_ROLE_PERMISSIONS) {
await query(
`INSERT INTO zen_auth_role_permissions (role_id, permission_key) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
[userId, permKey]
);
}
// Assign admin role to the first user if no user-roles exist yet
const userRolesCount = await query(`SELECT COUNT(*) FROM zen_auth_user_roles`);
if (parseInt(userRolesCount.rows[0].count) === 0) {
const firstUser = await query(`SELECT id FROM zen_auth_users ORDER BY created_at ASC LIMIT 1`);
if (firstUser.rows.length > 0) {
await query(
`INSERT INTO zen_auth_user_roles (user_id, role_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
[firstUser.rows[0].id, adminId]
);
}
}
}
export async function createTables() {
const created = [];
const skipped = [];
// Drop legacy CHECK constraint on zen_auth_users.role if it exists
await dropRoleCheckConstraint();
for (const table of ROLE_TABLES) {
const exists = await tableExists(table.name);
if (!exists) {
await query(table.sql);
created.push(table.name);
done(`Created table: ${table.name}`);
} else {
skipped.push(table.name);
}
}
await seedDefaultRolesAndPermissions();
return { created, skipped };
}
export async function dropTables() {
const dropOrder = [...ROLE_TABLES].reverse().map(t => t.name);
warn('Dropping all Zen role/permission tables...');
for (const tableName of dropOrder) {
const exists = await tableExists(tableName);
if (exists) {
await query(`DROP TABLE IF EXISTS "${tableName}" CASCADE`);
done(`Dropped table: ${tableName}`);
}
}
done('All role/permission tables dropped');
}
+7
View File
@@ -0,0 +1,7 @@
export { getUserById, getUserByEmail, countUsers, listUsers, updateUserById } from './queries.js';
export { register, login, requestPasswordReset, resetPassword, verifyUserEmail, updateUser } from './auth.js';
export { createSession, validateSession, deleteSession, deleteUserSessions, refreshSession } from './session.js';
export { createEmailVerification, verifyEmailToken, createPasswordReset, verifyResetToken, deleteResetToken } from './verifications.js';
export { hashPassword, verifyPassword, generateToken, generateId } from './password.js';
export { listRoles, getRoleById, createRole, updateRole, deleteRole, getUserRoles, assignUserRole, revokeUserRole } from './roles.js';
export { PERMISSIONS, PERMISSION_DEFINITIONS, getPermissionGroups, hasPermission, getUserPermissions } from './permissions.js';
+37
View File
@@ -0,0 +1,37 @@
import crypto from 'crypto';
async function hashPassword(password) {
return new Promise((resolve, reject) => {
const salt = crypto.randomBytes(16).toString('hex');
crypto.scrypt(password, salt, 64, (err, derivedKey) => {
if (err) reject(err);
resolve(salt + ':' + derivedKey.toString('hex'));
});
});
}
async function verifyPassword(password, hash) {
return new Promise((resolve, reject) => {
const [salt, key] = hash.split(':');
crypto.scrypt(password, salt, 64, (err, derivedKey) => {
if (err) { reject(err); return; }
try {
const storedKey = Buffer.from(key, 'hex');
if (storedKey.length !== derivedKey.length) { resolve(false); return; }
resolve(crypto.timingSafeEqual(storedKey, derivedKey));
} catch {
resolve(false);
}
});
});
}
function generateToken(length = 32) {
return crypto.randomBytes(length).toString('hex');
}
function generateId() {
return crypto.randomUUID();
}
export { hashPassword, verifyPassword, generateToken, generateId };
+26
View File
@@ -0,0 +1,26 @@
import { query } from '@zen/core/database';
export { PERMISSIONS, PERMISSION_DEFINITIONS, getPermissionGroups } from './constants.js';
export async function hasPermission(userId, permissionKey) {
const result = await query(
`SELECT 1
FROM zen_auth_user_roles ur
JOIN zen_auth_role_permissions rp ON rp.role_id = ur.role_id
WHERE ur.user_id = $1
AND rp.permission_key = $2
LIMIT 1`,
[userId, permissionKey]
);
return result.rows.length > 0;
}
export async function getUserPermissions(userId) {
const result = await query(
`SELECT DISTINCT rp.permission_key
FROM zen_auth_user_roles ur
JOIN zen_auth_role_permissions rp ON rp.role_id = ur.role_id
WHERE ur.user_id = $1`,
[userId]
);
return result.rows.map(r => r.permission_key);
}
+46
View File
@@ -0,0 +1,46 @@
import { query, findOne, updateById, count } from '@zen/core/database';
const MAX_PAGE_LIMIT = 100;
const ALLOWED_SORT_COLUMNS = ['id', 'email', 'name', 'role', 'email_verified', 'created_at'];
async function getUserById(id) {
return findOne('zen_auth_users', { id });
}
async function getUserByEmail(email) {
return findOne('zen_auth_users', { email });
}
async function countUsers() {
return count('zen_auth_users');
}
async function listUsers({ page = 1, limit = 10, sortBy = 'created_at', sortOrder = 'desc' } = {}) {
const safePage = Math.max(1, page);
const safeLimit = Math.min(Math.max(1, limit), MAX_PAGE_LIMIT);
const offset = (safePage - 1) * safeLimit;
const sortColumn = ALLOWED_SORT_COLUMNS.includes(sortBy) ? sortBy : 'created_at';
const order = sortOrder.toLowerCase() === 'asc' ? 'ASC' : 'DESC';
const result = await query(
`SELECT id, email, name, role, image, email_verified, created_at FROM zen_auth_users ORDER BY "${sortColumn}" ${order} LIMIT $1 OFFSET $2`,
[safeLimit, offset]
);
const total = await countUsers();
return {
users: result.rows,
pagination: { page: safePage, limit: safeLimit, total, totalPages: Math.ceil(total / safeLimit) }
};
}
async function updateUserById(id, fields) {
const allowed = ['name', 'role', 'email_verified', 'image', 'language', 'updated_at'];
const data = { updated_at: new Date() };
for (const key of allowed) {
if (fields[key] !== undefined) data[key] = fields[key];
}
return updateById('zen_auth_users', id, data);
}
export { getUserById, getUserByEmail, countUsers, listUsers, updateUserById };
+140
View File
@@ -0,0 +1,140 @@
import { query, transaction } from '@zen/core/database';
import { generateId } from './password.js';
import { PERMISSIONS } from './permissions.js';
const VALID_PERMISSION_KEYS = new Set(Object.values(PERMISSIONS));
export async function listRoles() {
const result = await query(
`SELECT r.*,
COUNT(DISTINCT rp.permission_key)::int AS permission_count,
COUNT(DISTINCT ur.user_id)::int AS user_count
FROM zen_auth_roles r
LEFT JOIN zen_auth_role_permissions rp ON rp.role_id = r.id
LEFT JOIN zen_auth_user_roles ur ON ur.role_id = r.id
GROUP BY r.id
ORDER BY r.created_at ASC`
);
return result.rows;
}
export async function getRoleById(id) {
const roleResult = await query(
`SELECT * FROM zen_auth_roles WHERE id = $1`,
[id]
);
if (roleResult.rows.length === 0) return null;
const permsResult = await query(
`SELECT permission_key FROM zen_auth_role_permissions WHERE role_id = $1`,
[id]
);
return {
...roleResult.rows[0],
permission_keys: permsResult.rows.map(r => r.permission_key)
};
}
export async function createRole({ name, description = null, color = '#6b7280' }) {
if (!name || !name.trim()) throw new Error('Role name is required');
const id = generateId();
const result = await query(
`INSERT INTO zen_auth_roles (id, name, description, color, is_system)
VALUES ($1, $2, $3, $4, false)
RETURNING *`,
[id, name.trim(), description || null, color]
);
return result.rows[0];
}
export async function updateRole(roleId, { name, description, color, permissionKeys }) {
const role = await query(`SELECT is_system FROM zen_auth_roles WHERE id = $1`, [roleId]);
if (role.rows.length === 0) throw new Error('Role not found');
const isSystem = role.rows[0].is_system;
return transaction(async (client) => {
const updateFields = [];
const values = [];
let idx = 1;
// System roles cannot be renamed
if (!isSystem && name !== undefined) {
if (!name.trim()) throw new Error('Role name cannot be empty');
updateFields.push(`name = $${idx++}`);
values.push(name.trim());
}
if (description !== undefined) {
updateFields.push(`description = $${idx++}`);
values.push(description || null);
}
if (color !== undefined) {
updateFields.push(`color = $${idx++}`);
values.push(color);
}
updateFields.push(`updated_at = $${idx++}`);
values.push(new Date());
values.push(roleId);
const updated = await client.query(
`UPDATE zen_auth_roles SET ${updateFields.join(', ')} WHERE id = $${idx} RETURNING *`,
values
);
if (permissionKeys !== undefined) {
const safeKeys = [...new Set(permissionKeys)].filter(k => VALID_PERMISSION_KEYS.has(k));
await client.query(`DELETE FROM zen_auth_role_permissions WHERE role_id = $1`, [roleId]);
for (const key of safeKeys) {
await client.query(
`INSERT INTO zen_auth_role_permissions (role_id, permission_key) VALUES ($1, $2)`,
[roleId, key]
);
}
}
const perms = await client.query(
`SELECT permission_key FROM zen_auth_role_permissions WHERE role_id = $1`,
[roleId]
);
return {
...updated.rows[0],
permission_keys: perms.rows.map(r => r.permission_key)
};
});
}
export async function deleteRole(roleId) {
const result = await query(`SELECT is_system FROM zen_auth_roles WHERE id = $1`, [roleId]);
if (result.rows.length === 0) throw new Error('Role not found');
if (result.rows[0].is_system) throw new Error('Cannot delete a system role');
await query(`DELETE FROM zen_auth_roles WHERE id = $1`, [roleId]);
}
export async function getUserRoles(userId) {
const result = await query(
`SELECT r.* FROM zen_auth_roles r
JOIN zen_auth_user_roles ur ON ur.role_id = r.id
WHERE ur.user_id = $1
ORDER BY r.created_at ASC`,
[userId]
);
return result.rows;
}
export async function assignUserRole(userId, roleId) {
await query(
`INSERT INTO zen_auth_user_roles (user_id, role_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
[userId, roleId]
);
}
export async function revokeUserRole(userId, roleId) {
await query(
`DELETE FROM zen_auth_user_roles WHERE user_id = $1 AND role_id = $2`,
[userId, roleId]
);
}
+79
View File
@@ -0,0 +1,79 @@
import { create, findOne, deleteWhere, updateById } from '@zen/core/database';
import { generateToken, generateId } from './password.js';
async function createSession(userId, options = {}) {
const { ipAddress, userAgent } = options;
const token = generateToken(32);
const sessionId = generateId();
const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + 30);
const session = await create('zen_auth_sessions', {
id: sessionId,
user_id: userId,
token,
expires_at: expiresAt,
ip_address: ipAddress || null,
user_agent: userAgent || null,
updated_at: new Date()
});
return session;
}
async function validateSession(token) {
if (!token) return null;
const session = await findOne('zen_auth_sessions', { token });
if (!session) return null;
if (new Date(session.expires_at) < new Date()) {
await deleteSession(token);
return null;
}
const user = await findOne('zen_auth_users', { id: session.user_id });
if (!user) {
await deleteSession(token);
return null;
}
const now = new Date();
const expiresAt = new Date(session.expires_at);
const daysUntilExpiry = Math.ceil((expiresAt - now) / (1000 * 60 * 60 * 24));
let sessionRefreshed = false;
if (daysUntilExpiry < 20) {
const newExpiresAt = new Date();
newExpiresAt.setDate(newExpiresAt.getDate() + 30);
await updateById('zen_auth_sessions', session.id, {
expires_at: newExpiresAt,
updated_at: new Date()
});
session.expires_at = newExpiresAt;
sessionRefreshed = true;
}
return { session, user, sessionRefreshed };
}
async function deleteSession(token) {
return await deleteWhere('zen_auth_sessions', { token });
}
async function deleteUserSessions(userId) {
return await deleteWhere('zen_auth_sessions', { user_id: userId });
}
async function refreshSession(token) {
const session = await findOne('zen_auth_sessions', { token });
if (!session) return null;
const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + 30);
return await updateById('zen_auth_sessions', session.id, {
expires_at: expiresAt,
updated_at: new Date()
});
}
export { createSession, validateSession, deleteSession, deleteUserSessions, refreshSession };
+101
View File
@@ -0,0 +1,101 @@
import crypto from 'crypto';
import { create, findOne, deleteWhere } from '@zen/core/database';
import { generateToken, generateId } from './password.js';
async function createEmailVerification(email) {
const token = generateToken(32);
const expiresAt = new Date();
expiresAt.setHours(expiresAt.getHours() + 24);
await deleteWhere('zen_auth_verifications', { identifier: 'email_verification', value: email });
const verification = await create('zen_auth_verifications', {
id: generateId(),
identifier: 'email_verification',
value: email,
token,
expires_at: expiresAt,
updated_at: new Date()
});
return { ...verification, token };
}
async function verifyEmailToken(email, token) {
const verification = await findOne('zen_auth_verifications', {
identifier: 'email_verification',
value: email
});
if (!verification) return false;
// Timing-safe comparison — always operate on same-length buffers so that a
// wrong-length guess yields no measurable timing difference from a wrong-value guess.
const storedBuf = Buffer.from(verification.token, 'utf8');
const providedBuf = Buffer.from(
token.length === verification.token.length ? token : verification.token,
'utf8'
);
const tokensMatch = crypto.timingSafeEqual(storedBuf, providedBuf)
&& token.length === verification.token.length;
if (!tokensMatch) return false;
if (new Date(verification.expires_at) < new Date()) {
await deleteWhere('zen_auth_verifications', { id: verification.id });
return false;
}
await deleteWhere('zen_auth_verifications', { id: verification.id });
return true;
}
async function createPasswordReset(email) {
const token = generateToken(32);
const expiresAt = new Date();
expiresAt.setHours(expiresAt.getHours() + 1);
await deleteWhere('zen_auth_verifications', { identifier: 'password_reset', value: email });
const reset = await create('zen_auth_verifications', {
id: generateId(),
identifier: 'password_reset',
value: email,
token,
expires_at: expiresAt,
updated_at: new Date()
});
return { ...reset, token };
}
async function verifyResetToken(email, token) {
const reset = await findOne('zen_auth_verifications', {
identifier: 'password_reset',
value: email
});
if (!reset) return false;
// Timing-safe comparison — same rationale as verifyEmailToken above.
const storedBuf = Buffer.from(reset.token, 'utf8');
const providedBuf = Buffer.from(
token.length === reset.token.length ? token : reset.token,
'utf8'
);
const tokensMatch = crypto.timingSafeEqual(storedBuf, providedBuf)
&& token.length === reset.token.length;
if (!tokensMatch) return false;
if (new Date(reset.expires_at) < new Date()) {
await deleteWhere('zen_auth_verifications', { id: reset.id });
return false;
}
return true;
}
function deleteResetToken(email) {
return deleteWhere('zen_auth_verifications', { identifier: 'password_reset', value: email });
}
export { createEmailVerification, verifyEmailToken, createPasswordReset, verifyResetToken, deleteResetToken };
@@ -4,6 +4,8 @@ import DashboardPage from './pages/DashboardPage.js';
import UsersPage from './pages/UsersPage.js';
import UserEditPage from './pages/UserEditPage.js';
import ProfilePage from './pages/ProfilePage.js';
import RolesPage from './pages/RolesPage.js';
import RoleEditPage from './pages/RoleEditPage.js';
export default function AdminPagesClient({ params, user, dashboardStats = null }) {
const parts = params?.admin || [];
@@ -13,10 +15,19 @@ export default function AdminPagesClient({ params, user, dashboardStats = null }
return <UserEditPage userId={parts[2]} user={user} />;
}
if (page === 'roles' && parts[1] === 'edit' && parts[2]) {
return <RoleEditPage roleId={parts[2]} user={user} />;
}
if (page === 'roles' && parts[1] === 'new') {
return <RoleEditPage roleId="new" user={user} />;
}
const corePages = {
dashboard: () => <DashboardPage user={user} stats={dashboardStats} />,
users: () => <UsersPage user={user} />,
profile: () => <ProfilePage user={user} />,
roles: () => <RolesPage user={user} />,
};
const CorePageComponent = corePages[page];
@@ -0,0 +1,268 @@
'use client';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { Card, Button } from '@zen/core/shared/components';
import { useToast } from '@zen/core/toast';
import { getPermissionGroups } from '@zen/core/users/constants';
const PERMISSION_GROUPS = getPermissionGroups();
const isNewRole = (roleId) => roleId === 'new';
const RoleEditPage = ({ roleId }) => {
const router = useRouter();
const toast = useToast();
const [loading, setLoading] = useState(!isNewRole(roleId));
const [saving, setSaving] = useState(false);
const [isSystem, setIsSystem] = useState(false);
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [color, setColor] = useState('#6b7280');
const [selectedPerms, setSelectedPerms] = useState([]);
useEffect(() => {
if (isNewRole(roleId)) return;
const fetchRole = async () => {
try {
const response = await fetch(`/zen/api/roles/${roleId}`, { credentials: 'include' });
if (!response.ok) {
toast.error('Rôle introuvable');
router.push('/admin/roles');
return;
}
const data = await response.json();
const role = data.role;
setName(role.name || '');
setDescription(role.description || '');
setColor(role.color || '#6b7280');
setSelectedPerms(role.permission_keys || []);
setIsSystem(role.is_system || false);
} catch (err) {
toast.error('Impossible de charger ce rôle');
router.push('/admin/roles');
} finally {
setLoading(false);
}
};
fetchRole();
}, [roleId]);
const togglePerm = (key) => {
setSelectedPerms(prev =>
prev.includes(key) ? prev.filter(k => k !== key) : [...prev, key]
);
};
const toggleGroup = (group) => {
const groupKeys = PERMISSION_GROUPS[group].map(p => p.key);
const allSelected = groupKeys.every(k => selectedPerms.includes(k));
if (allSelected) {
setSelectedPerms(prev => prev.filter(k => !groupKeys.includes(k)));
} else {
setSelectedPerms(prev => [...new Set([...prev, ...groupKeys])]);
}
};
const handleSubmit = async (e) => {
e.preventDefault();
if (!name.trim()) {
toast.error('Le nom du rôle est requis');
return;
}
setSaving(true);
try {
const isCreating = isNewRole(roleId);
const url = isCreating ? '/zen/api/roles' : `/zen/api/roles/${roleId}`;
const method = isCreating ? 'POST' : 'PUT';
const body = isCreating
? { name: name.trim(), description: description.trim() || null, color }
: { name: name.trim(), description: description.trim() || null, color, permissionKeys: selectedPerms };
const response = await fetch(url, {
method,
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
const data = await response.json();
if (!response.ok) {
toast.error(data.message || 'Impossible de sauvegarder ce rôle');
return;
}
toast.success(isCreating ? 'Rôle créé' : 'Rôle mis à jour');
// After creating, redirect to edit page so permissions can be set
if (isCreating && data.role?.id) {
router.push(`/admin/roles/edit/${data.role.id}`);
} else {
router.push('/admin/roles');
}
} catch (err) {
toast.error('Impossible de sauvegarder ce rôle');
} finally {
setSaving(false);
}
};
if (loading) {
return (
<div className="flex flex-col gap-6">
<div className="h-6 w-48 bg-neutral-200 dark:bg-neutral-700 rounded animate-pulse" />
<Card variant="default" padding="default">
<div className="h-40 bg-neutral-100 dark:bg-neutral-800 rounded animate-pulse" />
</Card>
</div>
);
}
const title = isNewRole(roleId) ? 'Nouveau rôle' : `Modifier "${name}"`;
return (
<div className="flex flex-col gap-4 sm:gap-6 lg:gap-8">
<div className="flex items-center gap-3">
<Button variant="secondary" size="sm" onClick={() => router.push('/admin/roles')}>
Retour
</Button>
<div>
<h1 className="text-lg sm:text-xl font-semibold text-neutral-900 dark:text-white">{title}</h1>
{isSystem && (
<p className="mt-1 text-xs text-neutral-400">Rôle système le nom ne peut pas être modifié</p>
)}
</div>
</div>
<form onSubmit={handleSubmit} className="flex flex-col gap-6">
{/* Basic info */}
<Card variant="default" padding="default">
<div className="flex flex-col gap-4">
<h2 className="text-sm font-semibold text-neutral-900 dark:text-white">Informations</h2>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium text-neutral-700 dark:text-neutral-300">
Nom du rôle
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
disabled={isSystem}
placeholder="Éditeur, Modérateur..."
className="w-full px-3 py-2 text-sm rounded-lg border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 text-neutral-900 dark:text-white disabled:opacity-50 disabled:cursor-not-allowed focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium text-neutral-700 dark:text-neutral-300">
Description
</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={2}
placeholder="Description optionnelle..."
className="w-full px-3 py-2 text-sm rounded-lg border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 text-neutral-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none"
/>
</div>
<div className="flex items-center gap-3">
<label className="text-xs font-medium text-neutral-700 dark:text-neutral-300">
Couleur
</label>
<input
type="color"
value={color}
onChange={(e) => setColor(e.target.value)}
className="w-8 h-8 rounded cursor-pointer border border-neutral-200 dark:border-neutral-700"
/>
<span className="text-xs text-neutral-500">{color}</span>
</div>
</div>
</Card>
{/* Permissions — only shown when editing, not when creating */}
{!isNewRole(roleId) && (
<Card variant="default" padding="default">
<div className="flex flex-col gap-4">
<h2 className="text-sm font-semibold text-neutral-900 dark:text-white">Permissions</h2>
{Object.entries(PERMISSION_GROUPS).map(([group, perms]) => {
const groupKeys = perms.map(p => p.key);
const allSelected = groupKeys.every(k => selectedPerms.includes(k));
const someSelected = groupKeys.some(k => selectedPerms.includes(k));
return (
<div key={group} className="flex flex-col gap-2">
<button
type="button"
onClick={() => toggleGroup(group)}
className="flex items-center gap-2 text-xs font-semibold text-neutral-500 dark:text-neutral-400 uppercase tracking-wide hover:text-neutral-700 dark:hover:text-neutral-200 text-left"
>
<span
className={`w-3.5 h-3.5 rounded border flex items-center justify-center flex-shrink-0 ${
allSelected
? 'bg-blue-600 border-blue-600'
: someSelected
? 'bg-blue-200 border-blue-400 dark:bg-blue-900 dark:border-blue-500'
: 'border-neutral-300 dark:border-neutral-600'
}`}
>
{allSelected && (
<svg className="w-2 h-2 text-white" viewBox="0 0 10 8" fill="none">
<path d="M1 4l3 3 5-6" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
)}
{someSelected && !allSelected && (
<span className="w-1.5 h-0.5 bg-blue-600 dark:bg-blue-400 rounded" />
)}
</span>
{group}
</button>
<div className="flex flex-col gap-1 pl-5">
{perms.map((perm) => (
<label
key={perm.key}
className="flex items-center gap-2.5 cursor-pointer group"
>
<input
type="checkbox"
checked={selectedPerms.includes(perm.key)}
onChange={() => togglePerm(perm.key)}
className="w-3.5 h-3.5 rounded border-neutral-300 dark:border-neutral-600 text-blue-600 focus:ring-blue-500"
/>
<span className="text-sm text-neutral-700 dark:text-neutral-300 group-hover:text-neutral-900 dark:group-hover:text-white">
{perm.name}
</span>
</label>
))}
</div>
</div>
);
})}
</div>
</Card>
)}
<div className="flex justify-end gap-3">
<Button variant="secondary" type="button" onClick={() => router.push('/admin/roles')}>
Annuler
</Button>
<Button variant="primary" type="submit" disabled={saving}>
{saving ? 'Sauvegarde...' : 'Sauvegarder'}
</Button>
</div>
</form>
</div>
);
};
export default RoleEditPage;
@@ -0,0 +1,163 @@
'use client';
import React, { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { Card, Table, Button } from '@zen/core/shared/components';
import { PencilEdit01Icon, Cancel01Icon } from '@zen/core/shared/icons';
import { useToast } from '@zen/core/toast';
const RolesPageClient = () => {
const router = useRouter();
const toast = useToast();
const [roles, setRoles] = useState([]);
const [loading, setLoading] = useState(true);
const columns = [
{
key: 'name',
label: 'Rôle',
sortable: false,
render: (role) => (
<div className="flex items-center gap-2">
<span
className="inline-block w-3 h-3 rounded-full flex-shrink-0"
style={{ backgroundColor: role.color || '#6b7280' }}
/>
<div>
<div className="text-sm font-medium text-neutral-900 dark:text-white">{role.name}</div>
{role.description && (
<div className="text-xs text-neutral-500 dark:text-gray-400">{role.description}</div>
)}
</div>
{role.is_system && (
<span className="text-xs px-1.5 py-0.5 rounded bg-neutral-100 dark:bg-neutral-700 text-neutral-500 dark:text-neutral-400">
système
</span>
)}
</div>
),
skeleton: { height: 'h-4', width: '60%' }
},
{
key: 'permission_count',
label: 'Permissions',
sortable: false,
render: (role) => (
<span className="text-sm text-neutral-600 dark:text-gray-300">{role.permission_count}</span>
),
skeleton: { height: 'h-4', width: '40px' }
},
{
key: 'user_count',
label: 'Utilisateurs',
sortable: false,
render: (role) => (
<span className="text-sm text-neutral-600 dark:text-gray-300">{role.user_count}</span>
),
skeleton: { height: 'h-4', width: '40px' }
},
{
key: 'actions',
label: '',
sortable: false,
noWrap: true,
render: (role) => (
<div className="flex items-center gap-2">
<Button
variant="secondary"
size="sm"
onClick={() => router.push(`/admin/roles/edit/${role.id}`)}
icon={<PencilEdit01Icon className="w-4 h-4" />}
>
Modifier
</Button>
{!role.is_system && (
<Button
variant="danger"
size="sm"
onClick={() => handleDelete(role)}
icon={<Cancel01Icon className="w-4 h-4" />}
>
Supprimer
</Button>
)}
</div>
),
skeleton: { height: 'h-8', width: '80px', className: 'rounded-lg' }
}
];
const fetchRoles = async () => {
setLoading(true);
try {
const response = await fetch('/zen/api/roles', { credentials: 'include' });
if (!response.ok) throw new Error(`Error: ${response.status}`);
const data = await response.json();
setRoles(data.roles);
} catch (err) {
toast.error(err.message || 'Impossible de charger les rôles');
} finally {
setLoading(false);
}
};
const handleDelete = async (role) => {
if (!confirm(`Supprimer le rôle "${role.name}" ?`)) return;
try {
const response = await fetch(`/zen/api/roles/${role.id}`, {
method: 'DELETE',
credentials: 'include'
});
const data = await response.json();
if (!response.ok) {
toast.error(data.message || 'Impossible de supprimer ce rôle');
return;
}
toast.success('Rôle supprimé');
fetchRoles();
} catch (err) {
toast.error('Impossible de supprimer ce rôle');
}
};
useEffect(() => {
fetchRoles();
}, []);
return (
<Card variant="default" padding="none">
<Table
columns={columns}
data={roles}
loading={loading}
emptyMessage="Aucun rôle trouvé"
emptyDescription="Créez un rôle pour commencer"
/>
</Card>
);
};
const RolesPage = () => {
const router = useRouter();
return (
<div className="flex flex-col gap-4 sm:gap-6 lg:gap-8">
<div className="flex items-center justify-between">
<div>
<h1 className="text-lg sm:text-xl font-semibold text-neutral-900 dark:text-white">Rôles</h1>
<p className="mt-1 text-xs text-neutral-400">Gérez les rôles et leurs permissions</p>
</div>
<Button
variant="primary"
size="sm"
onClick={() => router.push('/admin/roles/new')}
>
Nouveau rôle
</Button>
</div>
<RolesPageClient />
</div>
);
};
export default RolesPage;
+10 -39
View File
@@ -1,28 +1,10 @@
/**
* Admin Route Protection Middleware
* Utilities to protect admin routes and require admin role
*/
import { getSession } from '@zen/core/features/auth/actions';
import { hasPermission, PERMISSIONS } from '@zen/core/users';
import { redirect } from 'next/navigation';
/**
* Protect an admin page - requires authentication and admin role
* Use this in server components to require admin access
*
* @param {Object} options - Protection options
* @param {string} options.redirectTo - Where to redirect if not authenticated (default: '/auth/login')
* @param {string} options.forbiddenRedirect - Where to redirect if not admin (default: '/')
* @returns {Promise<Object>} Session object with user data
*
* @example
* // In a server component:
* import { protectAdmin } from '@zen/core/features/admin';
*
* export default async function AdminPage() {
* const session = await protectAdmin();
* return <div>Welcome Admin, {session.user.name}!</div>;
* }
* Protect an admin page - requires authentication and admin.access permission.
* Use this in server components to require admin access.
*/
async function protectAdmin(options = {}) {
const { redirectTo = '/auth/login', forbiddenRedirect = '/' } = options;
@@ -33,7 +15,8 @@ async function protectAdmin(options = {}) {
redirect(redirectTo);
}
if (session.user.role !== 'admin') {
const allowed = await hasPermission(session.user.id, PERMISSIONS.ADMIN_ACCESS);
if (!allowed) {
redirect(forbiddenRedirect);
}
@@ -41,25 +24,13 @@ async function protectAdmin(options = {}) {
}
/**
* Check if user is admin
* Use this when you want to check admin status without forcing a redirect
*
* @returns {Promise<boolean>} True if user is admin
*
* @example
* import { isAdmin } from '@zen/core/features/admin';
*
* export default async function Page() {
* const admin = await isAdmin();
* return admin ? <div>Admin panel</div> : <div>Access denied</div>;
* }
* Check if the current user has admin.access permission.
* Non-redirecting check for conditional rendering.
*/
async function isAdmin() {
const session = await getSession();
return session && session.user.role === 'admin';
if (!session) return false;
return hasPermission(session.user.id, PERMISSIONS.ADMIN_ACCESS);
}
export {
protectAdmin,
isAdmin
};
export { protectAdmin, isAdmin };
+6
View File
@@ -39,6 +39,12 @@ export function buildNavigationSections(pathname) {
icon: 'UserMultiple02Icon',
current: pathname.startsWith('/admin/users')
},
{
name: 'Rôles',
href: '/admin/roles',
icon: 'Crown03Icon',
current: pathname.startsWith('/admin/roles')
},
]
}
];
+141
View File
@@ -9,6 +9,7 @@
import { query, updateById } from '@zen/core/database';
import { updateUser } from './lib/auth.js';
import { listRoles, getRoleById, createRole, updateRole, deleteRole, getUserRoles, assignUserRole, revokeUserRole } from '@zen/core/users';
import { uploadImage, deleteFile, generateUniqueFilename, getFileExtension, FILE_TYPE_PRESETS, FILE_SIZE_LIMITS, validateUpload } from '@zen/core/storage';
const generateUserFilePath = (userId, category, filename) => `users/${userId}/${category}/${filename}`;
@@ -339,6 +340,138 @@ async function handleDeleteProfilePicture(_request, _params, { session }) {
}
}
// ---------------------------------------------------------------------------
// 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 } = 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'
});
return apiSuccess({ role });
} 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');
}
}
// ---------------------------------------------------------------------------
// Route definitions
// ---------------------------------------------------------------------------
@@ -352,6 +485,14 @@ export const routes = defineApiRoutes([
{ path: '/users/profile', method: 'PUT', handler: handleUpdateProfile, auth: 'user' },
{ path: '/users/profile/picture', method: 'POST', handler: handleUploadProfilePicture, auth: 'user' },
{ path: '/users/profile/picture', method: 'DELETE', handler: handleDeleteProfilePicture, auth: 'user' },
{ path: '/users/:id/roles', method: 'GET', handler: handleGetUserRoles, auth: 'admin' },
{ path: '/users/:id/roles', method: 'POST', handler: handleAssignUserRole, auth: 'admin' },
{ path: '/users/:id/roles/:roleId', method: 'DELETE', handler: handleRevokeUserRole, auth: 'admin' },
{ path: '/users/:id', method: 'GET', handler: handleGetUserById, auth: 'admin' },
{ path: '/users/:id', method: 'PUT', handler: handleUpdateUserById, auth: 'admin' },
{ path: '/roles', method: 'GET', handler: handleListRoles, auth: 'admin' },
{ path: '/roles', method: 'POST', handler: handleCreateRole, auth: 'admin' },
{ path: '/roles/:id', method: 'GET', handler: handleGetRole, auth: 'admin' },
{ path: '/roles/:id', method: 'PUT', handler: handleUpdateRole, auth: 'admin' },
{ path: '/roles/:id', method: 'DELETE', handler: handleDeleteRole, auth: 'admin' },
]);
+7
View File
@@ -5,6 +5,7 @@
import { query, tableExists } from '@zen/core/database';
import { done, warn } from '@zen/core/shared/logger';
import { createTables as createUserCoreTables, dropTables as dropUserCoreTables } from '../../core/users/db.js';
const AUTH_TABLES = [
{
@@ -93,6 +94,11 @@ export async function createTables() {
}
}
// Create role/permission tables and seed defaults
const coreResult = await createUserCoreTables();
created.push(...coreResult.created);
skipped.push(...coreResult.skipped);
return { created, skipped };
}
@@ -113,5 +119,6 @@ export async function dropTables() {
}
}
await dropUserCoreTables();
done('All authentication tables dropped');
}
+18 -297
View File
@@ -1,301 +1,22 @@
/**
* Authentication Logic
* Main authentication functions for user registration, login, and password management
*/
import { create, findOne, updateById, count } from '@zen/core/database';
import { hashPassword, verifyPassword, generateId } from './password.js';
import { createSession } from './session.js';
import { fail } from '@zen/core/shared/logger';
import { createEmailVerification, createPasswordReset, verifyResetToken, deleteResetToken, sendPasswordChangedEmail } from './email.js';
/**
* Register a new user
* @param {Object} userData - User registration data
* @param {string} userData.email - User email
* @param {string} userData.password - User password
* @param {string} userData.name - User name
* @returns {Promise<Object>} Created user and session
*/
async function register(userData) {
const { email, password, name } = userData;
// Validate required fields
if (!email || !password || !name) {
throw new Error('L\'e-mail, le mot de passe et le nom sont requis');
}
// Validate email length (maximum 254 characters - RFC standard)
if (email.length > 254) {
throw new Error('L\'e-mail doit contenir 254 caractères ou moins');
}
// Validate password length (minimum 8, maximum 128 characters)
if (password.length < 8) {
throw new Error('Le mot de passe doit contenir au moins 8 caractères');
}
if (password.length > 128) {
throw new Error('Le mot de passe doit contenir 128 caractères ou moins');
}
// Validate password complexity (1 uppercase, 1 lowercase, 1 number)
const hasUppercase = /[A-Z]/.test(password);
const hasLowercase = /[a-z]/.test(password);
const hasNumber = /\d/.test(password);
if (!hasUppercase || !hasLowercase || !hasNumber) {
throw new Error('Le mot de passe doit contenir au moins une majuscule, une minuscule et un chiffre');
}
// Validate name length (maximum 100 characters)
if (name.length > 100) {
throw new Error('Le nom doit contenir 100 caractères ou moins');
}
// Validate name is not empty after trimming
if (name.trim().length === 0) {
throw new Error('Le nom ne peut pas être vide');
}
// Check if user already exists
const existingUser = await findOne('zen_auth_users', { email });
if (existingUser) {
throw new Error('Un utilisateur avec cet e-mail existe déjà');
}
// Check if this is the first user - if so, make them admin
const userCount = await count('zen_auth_users');
const role = userCount === 0 ? 'admin' : 'user';
// Hash password
const hashedPassword = await hashPassword(password);
// Create user
const userId = generateId();
const user = await create('zen_auth_users', {
id: userId,
email,
name,
email_verified: false,
image: null,
role,
updated_at: new Date()
});
// Create account with password
const accountId = generateId();
await create('zen_auth_accounts', {
id: accountId,
account_id: email,
provider_id: 'credential',
user_id: user.id,
password: hashedPassword,
updated_at: new Date()
});
// Create email verification token
const verification = await createEmailVerification(email);
return {
user,
verificationToken: verification.token
};
}
/**
* Login a user
* @param {Object} credentials - Login credentials
* @param {string} credentials.email - User email
* @param {string} credentials.password - User password
* @param {Object} sessionOptions - Session options (ipAddress, userAgent)
* @returns {Promise<Object>} User and session
*/
async function login(credentials, sessionOptions = {}) {
const { email, password } = credentials;
// Validate required fields
if (!email || !password) {
throw new Error('L\'e-mail et le mot de passe sont requis');
}
// Find user
const user = await findOne('zen_auth_users', { email });
if (!user) {
throw new Error('E-mail ou mot de passe incorrect');
}
// Find account with password
const account = await findOne('zen_auth_accounts', {
user_id: user.id,
provider_id: 'credential'
});
if (!account || !account.password) {
throw new Error('E-mail ou mot de passe incorrect');
}
// Verify password
const isValid = await verifyPassword(password, account.password);
if (!isValid) {
throw new Error('E-mail ou mot de passe incorrect');
}
// Create session
const session = await createSession(user.id, sessionOptions);
return {
user,
session
};
}
/**
* Request a password reset
* @param {string} email - User email
* @returns {Promise<Object>} Reset token
*/
async function requestPasswordReset(email) {
// Validate email
if (!email) {
throw new Error('L\'e-mail est requis');
}
// Check if user exists
const user = await findOne('zen_auth_users', { email });
if (!user) {
// Don't reveal if user exists or not
return { success: true };
}
// Create password reset token
const reset = await createPasswordReset(email);
return {
success: true,
token: reset.token
};
}
/**
* Reset password with token
* @param {Object} resetData - Reset data
* @param {string} resetData.email - User email
* @param {string} resetData.token - Reset token
* @param {string} resetData.newPassword - New password
* @returns {Promise<Object>} Success status
*/
async function resetPassword(resetData) {
const { email, token, newPassword } = resetData;
// Validate required fields
if (!email || !token || !newPassword) {
throw new Error('L\'e-mail, le jeton et le nouveau mot de passe sont requis');
}
// Validate password length (minimum 8, maximum 128 characters)
if (newPassword.length < 8) {
throw new Error('Le mot de passe doit contenir au moins 8 caractères');
}
if (newPassword.length > 128) {
throw new Error('Le mot de passe doit contenir 128 caractères ou moins');
}
// Validate password complexity (1 uppercase, 1 lowercase, 1 number)
const hasUppercase = /[A-Z]/.test(newPassword);
const hasLowercase = /[a-z]/.test(newPassword);
const hasNumber = /\d/.test(newPassword);
if (!hasUppercase || !hasLowercase || !hasNumber) {
throw new Error('Le mot de passe doit contenir au moins une majuscule, une minuscule et un chiffre');
}
// Authoritative token verification — this check must live here so that any
// caller that imports resetPassword() directly (bypassing the server-action
// layer) cannot reset a password with an arbitrary or omitted token.
const tokenValid = await verifyResetToken(email, token);
if (!tokenValid) {
throw new Error('Jeton de réinitialisation invalide ou expiré');
}
// Find user
const user = await findOne('zen_auth_users', { email });
if (!user) {
throw new Error('Jeton de réinitialisation invalide');
}
// Find account
const account = await findOne('zen_auth_accounts', {
user_id: user.id,
provider_id: 'credential'
});
if (!account) {
throw new Error('Compte introuvable');
}
// Hash new password
const hashedPassword = await hashPassword(newPassword);
// Update password
await updateById('zen_auth_accounts', account.id, {
password: hashedPassword,
updated_at: new Date()
});
// Delete reset token
await deleteResetToken(email);
// Send password changed confirmation email
try {
await sendPasswordChangedEmail(email);
} catch (error) {
// Log error but don't fail the password reset process
fail(`Auth: failed to send password changed email to ${email}: ${error.message}`);
}
return { success: true };
}
/**
* Verify user email
* @param {string} userId - User ID
* @returns {Promise<Object>} Updated user
*/
async function verifyUserEmail(userId) {
return await updateById('zen_auth_users', userId, {
email_verified: true,
updated_at: new Date()
});
}
/**
* Update user profile
* @param {string} userId - User ID
* @param {Object} updateData - Data to update
* @returns {Promise<Object>} Updated user
*/
async function updateUser(userId, updateData) {
const allowedFields = ['name', 'image', 'language'];
const filteredData = {};
for (const field of allowedFields) {
if (updateData[field] !== undefined) {
filteredData[field] = updateData[field];
}
}
filteredData.updated_at = new Date();
return await updateById('zen_auth_users', userId, filteredData);
}
export {
register,
import {
register as _register,
resetPassword as _resetPassword,
login,
requestPasswordReset,
resetPassword,
verifyUserEmail,
updateUser
};
} from '../../../core/users/auth.js';
import { sendPasswordChangedEmail } from './email.js';
// Inject email sending into register (verification email) — kept here because
// it depends on JSX templates that live in features/auth.
export function register(userData) {
return _register(userData);
}
// Inject sendPasswordChangedEmail — the template lives in features/auth.
export function resetPassword(resetData) {
return _resetPassword(resetData, { onPasswordChanged: sendPasswordChangedEmail });
}
export { login, requestPasswordReset, verifyUserEmail, updateUser };
+3 -108
View File
@@ -1,108 +1,12 @@
import crypto from 'crypto';
import { render } from '@react-email/components';
import { create, findOne, deleteWhere } from '@zen/core/database';
import { generateToken, generateId } from './password.js';
import { fail, info } from '@zen/core/shared/logger';
import { sendEmail } from '@zen/core/email';
import { VerificationEmail } from '../templates/VerificationEmail.jsx';
import { PasswordResetEmail } from '../templates/PasswordResetEmail.jsx';
import { PasswordChangedEmail } from '../templates/PasswordChangedEmail.jsx';
async function createEmailVerification(email) {
const token = generateToken(32);
const expiresAt = new Date();
expiresAt.setHours(expiresAt.getHours() + 24);
await deleteWhere('zen_auth_verifications', { identifier: 'email_verification', value: email });
const verification = await create('zen_auth_verifications', {
id: generateId(),
identifier: 'email_verification',
value: email,
token,
expires_at: expiresAt,
updated_at: new Date()
});
return { ...verification, token };
}
async function verifyEmailToken(email, token) {
const verification = await findOne('zen_auth_verifications', {
identifier: 'email_verification',
value: email
});
if (!verification) return false;
// Timing-safe comparison — always operate on same-length buffers so that a
// wrong-length guess yields no measurable timing difference from a wrong-value guess.
const storedBuf = Buffer.from(verification.token, 'utf8');
const providedBuf = Buffer.from(
token.length === verification.token.length ? token : verification.token,
'utf8'
);
const tokensMatch = crypto.timingSafeEqual(storedBuf, providedBuf)
&& token.length === verification.token.length;
if (!tokensMatch) return false;
if (new Date(verification.expires_at) < new Date()) {
await deleteWhere('zen_auth_verifications', { id: verification.id });
return false;
}
await deleteWhere('zen_auth_verifications', { id: verification.id });
return true;
}
async function createPasswordReset(email) {
const token = generateToken(32);
const expiresAt = new Date();
expiresAt.setHours(expiresAt.getHours() + 1);
await deleteWhere('zen_auth_verifications', { identifier: 'password_reset', value: email });
const reset = await create('zen_auth_verifications', {
id: generateId(),
identifier: 'password_reset',
value: email,
token,
expires_at: expiresAt,
updated_at: new Date()
});
return { ...reset, token };
}
async function verifyResetToken(email, token) {
const reset = await findOne('zen_auth_verifications', {
identifier: 'password_reset',
value: email
});
if (!reset) return false;
// Timing-safe comparison — same rationale as verifyEmailToken above.
const storedBuf = Buffer.from(reset.token, 'utf8');
const providedBuf = Buffer.from(
token.length === reset.token.length ? token : reset.token,
'utf8'
);
const tokensMatch = crypto.timingSafeEqual(storedBuf, providedBuf)
&& token.length === reset.token.length;
if (!tokensMatch) return false;
if (new Date(reset.expires_at) < new Date()) {
await deleteWhere('zen_auth_verifications', { id: reset.id });
return false;
}
return true;
}
function deleteResetToken(email) {
return deleteWhere('zen_auth_verifications', { identifier: 'password_reset', value: email });
}
export { createEmailVerification, verifyEmailToken, createPasswordReset, verifyResetToken, deleteResetToken }
from '../../../core/users/verifications.js';
async function sendVerificationEmail(email, token, baseUrl) {
const appName = process.env.ZEN_NAME || 'ZEN';
@@ -142,13 +46,4 @@ async function sendPasswordChangedEmail(email) {
return result;
}
export {
createEmailVerification,
verifyEmailToken,
createPasswordReset,
verifyResetToken,
deleteResetToken,
sendVerificationEmail,
sendPasswordResetEmail,
sendPasswordChangedEmail
};
export { sendVerificationEmail, sendPasswordResetEmail, sendPasswordChangedEmail };
+1 -73
View File
@@ -1,73 +1 @@
/**
* Password Hashing and Verification
* Provides secure password hashing using bcrypt
*/
import crypto from 'crypto';
/**
* Hash a password using scrypt (Node.js native)
* @param {string} password - Plain text password
* @returns {Promise<string>} Hashed password
*/
async function hashPassword(password) {
return new Promise((resolve, reject) => {
// Generate a salt
const salt = crypto.randomBytes(16).toString('hex');
// Hash password with salt using scrypt
crypto.scrypt(password, salt, 64, (err, derivedKey) => {
if (err) reject(err);
resolve(salt + ':' + derivedKey.toString('hex'));
});
});
}
/**
* Verify a password against a hash
* @param {string} password - Plain text password
* @param {string} hash - Hashed password
* @returns {Promise<boolean>} True if password matches, false otherwise
*/
async function verifyPassword(password, hash) {
return new Promise((resolve, reject) => {
const [salt, key] = hash.split(':');
crypto.scrypt(password, salt, 64, (err, derivedKey) => {
if (err) { reject(err); return; }
try {
const storedKey = Buffer.from(key, 'hex');
// timingSafeEqual requires identical lengths; if the stored hash is
// malformed the lengths will differ and we reject without leaking timing.
if (storedKey.length !== derivedKey.length) { resolve(false); return; }
resolve(crypto.timingSafeEqual(storedKey, derivedKey));
} catch {
resolve(false);
}
});
});
}
/**
* Generate a random token
* @param {number} length - Token length in bytes (default: 32)
* @returns {string} Random token
*/
function generateToken(length = 32) {
return crypto.randomBytes(length).toString('hex');
}
/**
* Generate a random ID
* @returns {string} Random ID
*/
function generateId() {
return crypto.randomUUID();
}
export {
hashPassword,
verifyPassword,
generateToken,
generateId
};
export { hashPassword, verifyPassword, generateToken, generateId } from '../../../core/users/password.js';
+1 -138
View File
@@ -1,138 +1 @@
/**
* Session Management
* Handles user session creation, validation, and deletion
*/
import { create, findOne, deleteWhere, updateById } from '@zen/core/database';
import { generateToken, generateId } from './password.js';
/**
* Create a new session for a user
* @param {string} userId - User ID
* @param {Object} options - Session options (ipAddress, userAgent)
* @returns {Promise<Object>} Session object with token
*/
async function createSession(userId, options = {}) {
const { ipAddress, userAgent } = options;
// Generate session token
const token = generateToken(32);
const sessionId = generateId();
// Session expires in 30 days
const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + 30);
const session = await create('zen_auth_sessions', {
id: sessionId,
user_id: userId,
token,
expires_at: expiresAt,
ip_address: ipAddress || null,
user_agent: userAgent || null,
updated_at: new Date()
});
return session;
}
/**
* Validate a session token
* @param {string} token - Session token
* @returns {Promise<Object|null>} Session object with user data or null if invalid
*/
async function validateSession(token) {
if (!token) return null;
const session = await findOne('zen_auth_sessions', { token });
if (!session) return null;
// Check if session is expired
if (new Date(session.expires_at) < new Date()) {
await deleteSession(token);
return null;
}
// Get user data
const user = await findOne('zen_auth_users', { id: session.user_id });
if (!user) {
await deleteSession(token);
return null;
}
// Auto-refresh session if it expires in less than 20 days
const now = new Date();
const expiresAt = new Date(session.expires_at);
const daysUntilExpiry = Math.ceil((expiresAt - now) / (1000 * 60 * 60 * 24));
let sessionRefreshed = false;
if (daysUntilExpiry < 20) {
// Extend session to 30 days from now
const newExpiresAt = new Date();
newExpiresAt.setDate(newExpiresAt.getDate() + 30);
await updateById('zen_auth_sessions', session.id, {
expires_at: newExpiresAt,
updated_at: new Date()
});
// Update the session object with new expiration
session.expires_at = newExpiresAt;
sessionRefreshed = true;
}
return {
session,
user,
sessionRefreshed
};
}
/**
* Delete a session
* @param {string} token - Session token
* @returns {Promise<number>} Number of deleted sessions
*/
async function deleteSession(token) {
return await deleteWhere('zen_auth_sessions', { token });
}
/**
* Delete all sessions for a user
* @param {string} userId - User ID
* @returns {Promise<number>} Number of deleted sessions
*/
async function deleteUserSessions(userId) {
return await deleteWhere('zen_auth_sessions', { user_id: userId });
}
/**
* Refresh a session (extend expiration)
* @param {string} token - Session token
* @returns {Promise<Object|null>} Updated session or null
*/
async function refreshSession(token) {
const session = await findOne('zen_auth_sessions', { token });
if (!session) return null;
// Extend session by 30 days
const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + 30);
return await updateById('zen_auth_sessions', session.id, {
expires_at: expiresAt,
updated_at: new Date()
});
}
export {
createSession,
validateSession,
deleteSession,
deleteUserSessions,
refreshSession
};
export { createSession, validateSession, deleteSession, deleteUserSessions, refreshSession } from '../../../core/users/session.js';
+3 -1
View File
@@ -13,6 +13,8 @@ export default defineConfig([
'src/features/admin/actions.js',
'src/features/admin/pages.js',
'src/features/admin/components/index.js',
'src/core/users/index.js',
'src/core/users/constants.js',
'src/core/api/index.js',
'src/core/api/route-handler.js',
'src/core/cron/index.js',
@@ -36,7 +38,7 @@ export default defineConfig([
splitting: false,
sourcemap: false,
clean: true,
external: ['react', 'react-dom', 'next', 'pg', 'dotenv', 'dotenv/config', 'resend', '@react-email/components', 'node-cron', 'readline', 'crypto', 'url', 'fs', 'path', 'net', 'dns', 'tls', '@zen/core/api', '@zen/core/cron', '@zen/core/database', '@zen/core/email', '@zen/core/email/templates', '@zen/core/storage', '@zen/core/toast', '@zen/core/features/auth', '@zen/core/features/auth/actions', '@zen/core/features/auth/components', '@zen/core/shared/components', '@zen/core/shared/icons', '@zen/core/shared/logger', '@zen/core/shared/config', '@zen/core/shared/rate-limit', '@zen/core/themes', '@aws-sdk/client-s3', '@aws-sdk/s3-request-presigner'],
external: ['react', 'react-dom', 'next', 'pg', 'dotenv', 'dotenv/config', 'resend', '@react-email/components', 'node-cron', 'readline', 'crypto', 'url', 'fs', 'path', 'net', 'dns', 'tls', '@zen/core/users', '@zen/core/users/constants', '@zen/core/api', '@zen/core/cron', '@zen/core/database', '@zen/core/email', '@zen/core/email/templates', '@zen/core/storage', '@zen/core/toast', '@zen/core/features/auth', '@zen/core/features/auth/actions', '@zen/core/features/auth/components', '@zen/core/shared/components', '@zen/core/shared/icons', '@zen/core/shared/logger', '@zen/core/shared/config', '@zen/core/shared/rate-limit', '@zen/core/themes', '@aws-sdk/client-s3', '@aws-sdk/s3-request-presigner'],
noExternal: [],
bundle: true,
banner: {