diff --git a/package.json b/package.json index f31e7df..04bf2e3 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/src/core/api/router.js b/src/core/api/router.js index 1f57bec..c950338 100644 --- a/src/core/api/router.js +++ b/src/core/api/router.js @@ -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} 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'); } diff --git a/src/core/users/auth.js b/src/core/users/auth.js new file mode 100644 index 0000000..018c6ca --- /dev/null +++ b/src/core/users/auth.js @@ -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 }; diff --git a/src/core/users/constants.js b/src/core/users/constants.js new file mode 100644 index 0000000..3cdb13f --- /dev/null +++ b/src/core/users/constants.js @@ -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.} + */ +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; + }, {}); +} diff --git a/src/core/users/db.js b/src/core/users/db.js new file mode 100644 index 0000000..bdf225e --- /dev/null +++ b/src/core/users/db.js @@ -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'); +} diff --git a/src/core/users/index.js b/src/core/users/index.js new file mode 100644 index 0000000..52f6f99 --- /dev/null +++ b/src/core/users/index.js @@ -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'; diff --git a/src/core/users/password.js b/src/core/users/password.js new file mode 100644 index 0000000..836c977 --- /dev/null +++ b/src/core/users/password.js @@ -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 }; diff --git a/src/core/users/permissions.js b/src/core/users/permissions.js new file mode 100644 index 0000000..ca03128 --- /dev/null +++ b/src/core/users/permissions.js @@ -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); +} diff --git a/src/core/users/queries.js b/src/core/users/queries.js new file mode 100644 index 0000000..42744a3 --- /dev/null +++ b/src/core/users/queries.js @@ -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 }; diff --git a/src/core/users/roles.js b/src/core/users/roles.js new file mode 100644 index 0000000..eb60d4c --- /dev/null +++ b/src/core/users/roles.js @@ -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] + ); +} diff --git a/src/core/users/session.js b/src/core/users/session.js new file mode 100644 index 0000000..bbd4b14 --- /dev/null +++ b/src/core/users/session.js @@ -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 }; diff --git a/src/core/users/verifications.js b/src/core/users/verifications.js new file mode 100644 index 0000000..a8fad87 --- /dev/null +++ b/src/core/users/verifications.js @@ -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 }; diff --git a/src/features/admin/components/AdminPages.js b/src/features/admin/components/AdminPages.js index 9053752..6772d25 100644 --- a/src/features/admin/components/AdminPages.js +++ b/src/features/admin/components/AdminPages.js @@ -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 ; } + if (page === 'roles' && parts[1] === 'edit' && parts[2]) { + return ; + } + + if (page === 'roles' && parts[1] === 'new') { + return ; + } + const corePages = { dashboard: () => , users: () => , profile: () => , + roles: () => , }; const CorePageComponent = corePages[page]; diff --git a/src/features/admin/components/pages/RoleEditPage.js b/src/features/admin/components/pages/RoleEditPage.js new file mode 100644 index 0000000..4ffa0b9 --- /dev/null +++ b/src/features/admin/components/pages/RoleEditPage.js @@ -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 ( +
+
+ +
+ +
+ ); + } + + const title = isNewRole(roleId) ? 'Nouveau rôle' : `Modifier "${name}"`; + + return ( +
+
+ +
+

{title}

+ {isSystem && ( +

Rôle système — le nom ne peut pas être modifié

+ )} +
+
+ +
+ {/* Basic info */} + +
+

Informations

+ +
+ + 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" + /> +
+ +
+ +