feat(users): refactor users system
This commit is contained in:
@@ -83,6 +83,12 @@
|
|||||||
"./features/provider": {
|
"./features/provider": {
|
||||||
"import": "./dist/features/provider/index.js"
|
"import": "./dist/features/provider/index.js"
|
||||||
},
|
},
|
||||||
|
"./users": {
|
||||||
|
"import": "./dist/core/users/index.js"
|
||||||
|
},
|
||||||
|
"./users/constants": {
|
||||||
|
"import": "./dist/core/users/constants.js"
|
||||||
|
},
|
||||||
"./api": {
|
"./api": {
|
||||||
"import": "./dist/core/api/index.js"
|
"import": "./dist/core/api/index.js"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { cookies } from 'next/headers';
|
|||||||
import { getSessionCookieName } from '@zen/core/shared/config';
|
import { getSessionCookieName } from '@zen/core/shared/config';
|
||||||
import { checkRateLimit, getIpFromRequest, formatRetryAfter } from '@zen/core/shared/rate-limit';
|
import { checkRateLimit, getIpFromRequest, formatRetryAfter } from '@zen/core/shared/rate-limit';
|
||||||
import { fail } from '@zen/core/shared/logger';
|
import { fail } from '@zen/core/shared/logger';
|
||||||
|
import { hasPermission, PERMISSIONS } from '@zen/core/users';
|
||||||
import { getCoreRoutes } from './core-routes.js';
|
import { getCoreRoutes } from './core-routes.js';
|
||||||
import { getFeatureRoutes, getSessionResolver } from './runtime.js';
|
import { getFeatureRoutes, getSessionResolver } from './runtime.js';
|
||||||
import { apiError } from './respond.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
|
* @returns {Promise<Object>} session
|
||||||
*/
|
*/
|
||||||
export async function requireAdmin() {
|
export async function requireAdmin() {
|
||||||
const session = await requireAuth();
|
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');
|
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 UsersPage from './pages/UsersPage.js';
|
||||||
import UserEditPage from './pages/UserEditPage.js';
|
import UserEditPage from './pages/UserEditPage.js';
|
||||||
import ProfilePage from './pages/ProfilePage.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 }) {
|
export default function AdminPagesClient({ params, user, dashboardStats = null }) {
|
||||||
const parts = params?.admin || [];
|
const parts = params?.admin || [];
|
||||||
@@ -13,10 +15,19 @@ export default function AdminPagesClient({ params, user, dashboardStats = null }
|
|||||||
return <UserEditPage userId={parts[2]} user={user} />;
|
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 = {
|
const corePages = {
|
||||||
dashboard: () => <DashboardPage user={user} stats={dashboardStats} />,
|
dashboard: () => <DashboardPage user={user} stats={dashboardStats} />,
|
||||||
users: () => <UsersPage user={user} />,
|
users: () => <UsersPage user={user} />,
|
||||||
profile: () => <ProfilePage user={user} />,
|
profile: () => <ProfilePage user={user} />,
|
||||||
|
roles: () => <RolesPage user={user} />,
|
||||||
};
|
};
|
||||||
|
|
||||||
const CorePageComponent = corePages[page];
|
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,65 +1,36 @@
|
|||||||
/**
|
|
||||||
* Admin Route Protection Middleware
|
|
||||||
* Utilities to protect admin routes and require admin role
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { getSession } from '@zen/core/features/auth/actions';
|
import { getSession } from '@zen/core/features/auth/actions';
|
||||||
|
import { hasPermission, PERMISSIONS } from '@zen/core/users';
|
||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Protect an admin page - requires authentication and admin role
|
* Protect an admin page - requires authentication and admin.access permission.
|
||||||
* Use this in server components to require admin access
|
* 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>;
|
|
||||||
* }
|
|
||||||
*/
|
*/
|
||||||
async function protectAdmin(options = {}) {
|
async function protectAdmin(options = {}) {
|
||||||
const { redirectTo = '/auth/login', forbiddenRedirect = '/' } = options;
|
const { redirectTo = '/auth/login', forbiddenRedirect = '/' } = options;
|
||||||
|
|
||||||
const session = await getSession();
|
const session = await getSession();
|
||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
redirect(redirectTo);
|
redirect(redirectTo);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (session.user.role !== 'admin') {
|
const allowed = await hasPermission(session.user.id, PERMISSIONS.ADMIN_ACCESS);
|
||||||
|
if (!allowed) {
|
||||||
redirect(forbiddenRedirect);
|
redirect(forbiddenRedirect);
|
||||||
}
|
}
|
||||||
|
|
||||||
return session;
|
return session;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if user is admin
|
* Check if the current user has admin.access permission.
|
||||||
* Use this when you want to check admin status without forcing a redirect
|
* Non-redirecting check for conditional rendering.
|
||||||
*
|
|
||||||
* @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>;
|
|
||||||
* }
|
|
||||||
*/
|
*/
|
||||||
async function isAdmin() {
|
async function isAdmin() {
|
||||||
const session = await getSession();
|
const session = await getSession();
|
||||||
return session && session.user.role === 'admin';
|
if (!session) return false;
|
||||||
|
return hasPermission(session.user.id, PERMISSIONS.ADMIN_ACCESS);
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export { protectAdmin, isAdmin };
|
||||||
protectAdmin,
|
|
||||||
isAdmin
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -39,6 +39,12 @@ export function buildNavigationSections(pathname) {
|
|||||||
icon: 'UserMultiple02Icon',
|
icon: 'UserMultiple02Icon',
|
||||||
current: pathname.startsWith('/admin/users')
|
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 { query, updateById } from '@zen/core/database';
|
||||||
import { updateUser } from './lib/auth.js';
|
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';
|
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}`;
|
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
|
// Route definitions
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -347,11 +480,19 @@ async function handleDeleteProfilePicture(_request, _params, { session }) {
|
|||||||
// parameterised paths (/users/:id) so they match first.
|
// parameterised paths (/users/:id) so they match first.
|
||||||
|
|
||||||
export const routes = defineApiRoutes([
|
export const routes = defineApiRoutes([
|
||||||
{ path: '/users', method: 'GET', handler: handleListUsers, auth: 'admin' },
|
{ path: '/users', method: 'GET', handler: handleListUsers, auth: 'admin' },
|
||||||
{ path: '/users/me', method: 'GET', handler: handleGetCurrentUser, auth: 'user' },
|
{ path: '/users/me', method: 'GET', handler: handleGetCurrentUser, auth: 'user' },
|
||||||
{ path: '/users/profile', method: 'PUT', handler: handleUpdateProfile, 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: 'POST', handler: handleUploadProfilePicture, auth: 'user' },
|
||||||
{ path: '/users/profile/picture', method: 'DELETE', handler: handleDeleteProfilePicture, auth: 'user' },
|
{ path: '/users/profile/picture', method: 'DELETE', handler: handleDeleteProfilePicture, auth: 'user' },
|
||||||
{ path: '/users/:id', method: 'GET', handler: handleGetUserById, auth: 'admin' },
|
{ path: '/users/:id/roles', method: 'GET', handler: handleGetUserRoles, auth: 'admin' },
|
||||||
{ path: '/users/:id', method: 'PUT', handler: handleUpdateUserById, 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 { query, tableExists } from '@zen/core/database';
|
||||||
import { done, warn } from '@zen/core/shared/logger';
|
import { done, warn } from '@zen/core/shared/logger';
|
||||||
|
import { createTables as createUserCoreTables, dropTables as dropUserCoreTables } from '../../core/users/db.js';
|
||||||
|
|
||||||
const AUTH_TABLES = [
|
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 };
|
return { created, skipped };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,5 +119,6 @@ export async function dropTables() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await dropUserCoreTables();
|
||||||
done('All authentication tables dropped');
|
done('All authentication tables dropped');
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-297
@@ -1,301 +1,22 @@
|
|||||||
/**
|
import {
|
||||||
* Authentication Logic
|
register as _register,
|
||||||
* Main authentication functions for user registration, login, and password management
|
resetPassword as _resetPassword,
|
||||||
*/
|
|
||||||
|
|
||||||
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,
|
|
||||||
login,
|
login,
|
||||||
requestPasswordReset,
|
requestPasswordReset,
|
||||||
resetPassword,
|
|
||||||
verifyUserEmail,
|
verifyUserEmail,
|
||||||
updateUser
|
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 { 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 { fail, info } from '@zen/core/shared/logger';
|
||||||
import { sendEmail } from '@zen/core/email';
|
import { sendEmail } from '@zen/core/email';
|
||||||
import { VerificationEmail } from '../templates/VerificationEmail.jsx';
|
import { VerificationEmail } from '../templates/VerificationEmail.jsx';
|
||||||
import { PasswordResetEmail } from '../templates/PasswordResetEmail.jsx';
|
import { PasswordResetEmail } from '../templates/PasswordResetEmail.jsx';
|
||||||
import { PasswordChangedEmail } from '../templates/PasswordChangedEmail.jsx';
|
import { PasswordChangedEmail } from '../templates/PasswordChangedEmail.jsx';
|
||||||
|
|
||||||
async function createEmailVerification(email) {
|
export { createEmailVerification, verifyEmailToken, createPasswordReset, verifyResetToken, deleteResetToken }
|
||||||
const token = generateToken(32);
|
from '../../../core/users/verifications.js';
|
||||||
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 });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function sendVerificationEmail(email, token, baseUrl) {
|
async function sendVerificationEmail(email, token, baseUrl) {
|
||||||
const appName = process.env.ZEN_NAME || 'ZEN';
|
const appName = process.env.ZEN_NAME || 'ZEN';
|
||||||
@@ -142,13 +46,4 @@ async function sendPasswordChangedEmail(email) {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export { sendVerificationEmail, sendPasswordResetEmail, sendPasswordChangedEmail };
|
||||||
createEmailVerification,
|
|
||||||
verifyEmailToken,
|
|
||||||
createPasswordReset,
|
|
||||||
verifyResetToken,
|
|
||||||
deleteResetToken,
|
|
||||||
sendVerificationEmail,
|
|
||||||
sendPasswordResetEmail,
|
|
||||||
sendPasswordChangedEmail
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,73 +1 @@
|
|||||||
/**
|
export { hashPassword, verifyPassword, generateToken, generateId } from '../../../core/users/password.js';
|
||||||
* 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
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,138 +1 @@
|
|||||||
/**
|
export { createSession, validateSession, deleteSession, deleteUserSessions, refreshSession } from '../../../core/users/session.js';
|
||||||
* 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
|
|
||||||
};
|
|
||||||
|
|||||||
+3
-1
@@ -13,6 +13,8 @@ export default defineConfig([
|
|||||||
'src/features/admin/actions.js',
|
'src/features/admin/actions.js',
|
||||||
'src/features/admin/pages.js',
|
'src/features/admin/pages.js',
|
||||||
'src/features/admin/components/index.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/index.js',
|
||||||
'src/core/api/route-handler.js',
|
'src/core/api/route-handler.js',
|
||||||
'src/core/cron/index.js',
|
'src/core/cron/index.js',
|
||||||
@@ -36,7 +38,7 @@ export default defineConfig([
|
|||||||
splitting: false,
|
splitting: false,
|
||||||
sourcemap: false,
|
sourcemap: false,
|
||||||
clean: true,
|
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: [],
|
noExternal: [],
|
||||||
bundle: true,
|
bundle: true,
|
||||||
banner: {
|
banner: {
|
||||||
|
|||||||
Reference in New Issue
Block a user