feat(users): refactor users system
This commit is contained in:
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
}, {});
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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 };
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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]
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
@@ -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 };
|
||||
|
||||
@@ -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')
|
||||
},
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
+148
-7
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -347,11 +480,19 @@ async function handleDeleteProfilePicture(_request, _params, { session }) {
|
||||
// parameterised paths (/users/:id) so they match first.
|
||||
|
||||
export const routes = defineApiRoutes([
|
||||
{ path: '/users', method: 'GET', handler: handleListUsers, auth: 'admin' },
|
||||
{ path: '/users/me', method: 'GET', handler: handleGetCurrentUser, auth: 'user' },
|
||||
{ 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', method: 'GET', handler: handleGetUserById, auth: 'admin' },
|
||||
{ path: '/users/:id', method: 'PUT', handler: handleUpdateUserById, auth: 'admin' },
|
||||
{ path: '/users', method: 'GET', handler: handleListUsers, auth: 'admin' },
|
||||
{ path: '/users/me', method: 'GET', handler: handleGetCurrentUser, auth: 'user' },
|
||||
{ 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' },
|
||||
]);
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
|
||||
@@ -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 +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 +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
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user