feat(modules): add external module system with auto-discovery and public pages support
- add `src/core/modules/` with registry, discovery (server), and public index - add `src/core/public-pages/` with registry, server component, and public index - add `src/core/users/permissions-registry.js` for runtime permission registration - expose `./modules`, `./public-pages`, and `./public-pages/server` package exports - rename `registerFeatureRoutes` to `registerApiRoutes` with backward-compatible alias - extend `seedDefaultRolesAndPermissions` to include module-registered permissions - update `initializeZen` and shared init to wire module discovery and registration - add `docs/MODULES.md` documenting the `@zen/module-*` authoring contract - update `docs/DEV.md` with references to module system docs
This commit is contained in:
@@ -9,7 +9,7 @@
|
||||
export { routeRequest, requireAuth, requireAdmin } from './router.js';
|
||||
|
||||
// Runtime state — session resolver + feature routes registry
|
||||
export { configureRouter, getSessionResolver, clearRouterConfig, registerFeatureRoutes, getFeatureRoutes, clearFeatureRoutes } from './runtime.js';
|
||||
export { configureRouter, getSessionResolver, clearRouterConfig, registerApiRoutes, registerFeatureRoutes, getFeatureRoutes, clearFeatureRoutes } from './runtime.js';
|
||||
|
||||
// Response utilities — use in all handlers (core and modules)
|
||||
export { apiSuccess, apiError, getStatusCode } from './respond.js';
|
||||
|
||||
+11
-4
@@ -68,18 +68,25 @@ if (!globalThis[REGISTRY_KEY]) globalThis[REGISTRY_KEY] = [];
|
||||
const _featureRoutes = globalThis[REGISTRY_KEY];
|
||||
|
||||
/**
|
||||
* Enregistre les routes d'une feature core.
|
||||
* Appelé une fois par feature pendant initializeZen().
|
||||
* Enregistre des routes API.
|
||||
* Appelé une fois par feature core ou module externe pendant initializeZen()
|
||||
* ou depuis le hook register() d'un module.
|
||||
*
|
||||
* @param {ReadonlyArray} routes - Définitions produites par defineApiRoutes()
|
||||
*/
|
||||
export function registerFeatureRoutes(routes) {
|
||||
export function registerApiRoutes(routes) {
|
||||
if (!Array.isArray(routes)) {
|
||||
throw new TypeError('registerFeatureRoutes: routes must be an array');
|
||||
throw new TypeError('registerApiRoutes: routes must be an array');
|
||||
}
|
||||
_featureRoutes.push(...routes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Alias rétro-compatible de registerApiRoutes.
|
||||
* @deprecated Utiliser registerApiRoutes.
|
||||
*/
|
||||
export const registerFeatureRoutes = registerApiRoutes;
|
||||
|
||||
/**
|
||||
* Retourne toutes les routes de features enregistrées.
|
||||
* Appelé à chaque requête par le router pour construire la liste complète.
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# Modules
|
||||
|
||||
Registre des modules `@zen/module-*` chargés dans le projet consommateur. Voir [docs/MODULES.md](../../../docs/MODULES.md) pour le guide complet de création d'un module.
|
||||
|
||||
## API
|
||||
|
||||
```js
|
||||
import { registerModule, getRegisteredModules } from '@zen/core/modules';
|
||||
|
||||
// Le core utilise discoverModules() pour peupler ce registre automatiquement.
|
||||
// La plupart des consommateurs n'appellent jamais registerModule() directement.
|
||||
```
|
||||
|
||||
## Forme attendue d'un module
|
||||
|
||||
Le point d'entrée d'un package `@zen/module-X` doit exporter :
|
||||
|
||||
| Export | Type | Obligatoire |
|
||||
|--------|------|-------------|
|
||||
| `manifest` | `{ name, version, permissions?, envVars? }` | oui |
|
||||
| `register` | `() => void \| Promise<void>` | oui |
|
||||
| `createTables` | `async () => { created?, skipped? }` | si le module a des tables |
|
||||
| `dropTables` | `async () => void` | si le module a des tables |
|
||||
@@ -0,0 +1,129 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { resolve, join } from 'node:path';
|
||||
import { createRequire } from 'node:module';
|
||||
import { info, warn, fail } from '@zen/core/shared/logger';
|
||||
import { registerModule, getRegisteredModule } from './registry.js';
|
||||
|
||||
/**
|
||||
* Découverte automatique des modules `@zen/module-*` installés dans le projet consommateur.
|
||||
*
|
||||
* Stratégie :
|
||||
* 1. Lire `package.json` du process.cwd() (le projet consommateur, pas @zen/core).
|
||||
* 2. Pour chaque dépendance dont le nom matche `^@zen/module-` ou `^zen-module-`,
|
||||
* résoudre son point d'entrée et l'importer.
|
||||
* 3. Pour les noms qui ne matchent pas le préfixe, fallback : lire
|
||||
* `keywords` du package.json du package — si "zen-module" est présent, charger.
|
||||
* 4. Valider la forme du module (manifest, register, createTables/dropTables) et
|
||||
* l'enregistrer via registerModule().
|
||||
*
|
||||
* Cette fonction ne lance PAS les hooks register() — elle se contente de découvrir
|
||||
* et d'enregistrer les modules dans le registre. Le boot (initializeZen) et le CLI
|
||||
* (zen-db) consomment ensuite getRegisteredModules() selon leurs besoins.
|
||||
*
|
||||
* Idempotente : appeler plusieurs fois ne charge chaque module qu'une seule fois.
|
||||
*/
|
||||
const NAME_PREFIX = /^(@zen\/module-|zen-module-)/;
|
||||
|
||||
function isCandidate(name) {
|
||||
return NAME_PREFIX.test(name);
|
||||
}
|
||||
|
||||
async function readJson(path) {
|
||||
try {
|
||||
return JSON.parse(await readFile(path, 'utf-8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function isThirdPartyModule(name, projectCwd) {
|
||||
// Fallback pour les modules tiers : on regarde le keywords du package.
|
||||
const require = createRequire(join(projectCwd, 'package.json'));
|
||||
let pkgJsonPath;
|
||||
try {
|
||||
pkgJsonPath = require.resolve(`${name}/package.json`);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
const pkg = await readJson(pkgJsonPath);
|
||||
return Array.isArray(pkg?.keywords) && pkg.keywords.includes('zen-module');
|
||||
}
|
||||
|
||||
async function loadModule(name) {
|
||||
if (getRegisteredModule(name)) return; // déjà chargé
|
||||
|
||||
let mod;
|
||||
try {
|
||||
// Node résout via node_modules à partir du module appelant ; en pratique
|
||||
// depuis dist/core/modules/ dans @zen/core (lui-même installé chez le
|
||||
// consommateur), Node remonte jusqu'aux node_modules du consommateur.
|
||||
mod = await import(name);
|
||||
} catch (error) {
|
||||
fail(`zen-modules: failed to import "${name}" — ${error.message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!mod.manifest || typeof mod.register !== 'function') {
|
||||
warn(`zen-modules: "${name}" missing required exports (manifest, register) — skipping`);
|
||||
return;
|
||||
}
|
||||
|
||||
registerModule({
|
||||
manifest: mod.manifest,
|
||||
register: mod.register,
|
||||
createTables: mod.createTables,
|
||||
dropTables: mod.dropTables,
|
||||
});
|
||||
info(`zen-modules: discovered ${mod.manifest.name}@${mod.manifest.version ?? '?'}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Découvre et enregistre tous les modules installés dans le projet consommateur.
|
||||
*
|
||||
* @param {object} [options]
|
||||
* @param {string} [options.cwd] - Répertoire racine du projet consommateur.
|
||||
* @returns {Promise<{ loaded: string[] }>}
|
||||
*/
|
||||
export async function discoverModules({ cwd = process.cwd() } = {}) {
|
||||
const pkgPath = resolve(cwd, 'package.json');
|
||||
const pkg = await readJson(pkgPath);
|
||||
if (!pkg) {
|
||||
warn(`zen-modules: no package.json at ${pkgPath} — skipping discovery`);
|
||||
return { loaded: [] };
|
||||
}
|
||||
|
||||
const allDeps = {
|
||||
...(pkg.dependencies ?? {}),
|
||||
...(pkg.devDependencies ?? {}),
|
||||
};
|
||||
|
||||
const candidates = [];
|
||||
for (const name of Object.keys(allDeps)) {
|
||||
if (isCandidate(name)) {
|
||||
candidates.push(name);
|
||||
} else if (await isThirdPartyModule(name, cwd)) {
|
||||
candidates.push(name);
|
||||
}
|
||||
}
|
||||
|
||||
for (const name of candidates) {
|
||||
await loadModule(name);
|
||||
}
|
||||
|
||||
return { loaded: candidates };
|
||||
}
|
||||
|
||||
/**
|
||||
* Valide les variables d'environnement requises par chaque module.
|
||||
* Ne lance pas — log un warning pour chaque variable absente.
|
||||
*/
|
||||
export function validateModuleEnvVars(modules) {
|
||||
for (const mod of modules) {
|
||||
const envVars = mod.manifest?.envVars ?? [];
|
||||
for (const v of envVars) {
|
||||
if (v.required && !process.env[v.key]) {
|
||||
warn(`zen-modules: ${mod.manifest.name} requires env var "${v.key}" — ${v.description ?? ''}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { registerModule, getRegisteredModules, getRegisteredModule, clearRegisteredModules } from './registry.js';
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Registre des modules `@zen/module-*` chargés.
|
||||
*
|
||||
* Un module est un package npm exportant :
|
||||
* - manifest : { name, version, permissions?, envVars? }
|
||||
* - register : () => void | Promise<void>
|
||||
* - createTables : async () => { created?: string[], skipped?: string[] }
|
||||
* - dropTables : async () => void
|
||||
*
|
||||
* La découverte (`discover.server.js`) lit le package.json du projet
|
||||
* consommateur et appelle registerModule() pour chaque dépendance détectée.
|
||||
*
|
||||
* Persisté via Symbol.for sur globalThis pour survivre aux hot-reloads.
|
||||
*/
|
||||
|
||||
const REGISTRY_KEY = Symbol.for('__ZEN_MODULES_REGISTRY__');
|
||||
if (!globalThis[REGISTRY_KEY]) globalThis[REGISTRY_KEY] = new Map();
|
||||
/** @type {Map<string, object>} */
|
||||
const registry = globalThis[REGISTRY_KEY];
|
||||
|
||||
export function registerModule(mod) {
|
||||
if (!mod || typeof mod !== 'object') {
|
||||
throw new TypeError('registerModule: argument must be an object');
|
||||
}
|
||||
const { manifest } = mod;
|
||||
if (!manifest || typeof manifest.name !== 'string' || !manifest.name) {
|
||||
throw new TypeError('registerModule: module.manifest.name must be a non-empty string');
|
||||
}
|
||||
if (typeof mod.register !== 'function') {
|
||||
throw new TypeError(`registerModule(${manifest.name}): module.register must be a function`);
|
||||
}
|
||||
registry.set(manifest.name, mod);
|
||||
}
|
||||
|
||||
export function getRegisteredModules() {
|
||||
return [...registry.values()];
|
||||
}
|
||||
|
||||
export function getRegisteredModule(name) {
|
||||
return registry.get(name);
|
||||
}
|
||||
|
||||
export function clearRegisteredModules() {
|
||||
registry.clear();
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
import { getPublicModulePage } from './registry.js';
|
||||
|
||||
/**
|
||||
* Composant serveur RSC catch-all pour `/zen/<module>/<...>`.
|
||||
*
|
||||
* Next.js route ce composant via un segment `[...path]`. Le premier segment
|
||||
* identifie le module ; le reste est passé au composant enregistré qui fait
|
||||
* son propre routage interne.
|
||||
*
|
||||
* `/zen/api/...` est intercepté en amont par la route API (`route.js`) qui
|
||||
* est plus spécifique pour Next.js — ce composant ne le verra jamais en
|
||||
* pratique, mais on garde le filtre par sûreté.
|
||||
*/
|
||||
export default async function PublicModulePage({ params }) {
|
||||
const resolved = await params;
|
||||
const path = Array.isArray(resolved?.path) ? resolved.path : [];
|
||||
|
||||
if (path.length === 0) notFound();
|
||||
const [moduleName, ...rest] = path;
|
||||
if (moduleName === 'api') notFound();
|
||||
|
||||
const entry = getPublicModulePage(moduleName);
|
||||
if (!entry) notFound();
|
||||
|
||||
const { Component } = entry;
|
||||
return <Component params={resolved} segments={rest} />;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
# Public Module Pages
|
||||
|
||||
Registre runtime pour les pages publiques `/zen/<module>/<...>` ajoutées par les modules externes.
|
||||
|
||||
## Concept
|
||||
|
||||
Tout chemin `/zen/<segment>/...` (sauf `/zen/api/...` réservé aux routes API) est résolu vers le composant enregistré sous `<segment>`. Le module gère son routage interne.
|
||||
|
||||
## API
|
||||
|
||||
```js
|
||||
import { registerPublicModulePage } from '@zen/core/public-pages';
|
||||
|
||||
registerPublicModulePage({
|
||||
moduleName: 'billing',
|
||||
Component: BillingRouter,
|
||||
title: 'Facturation',
|
||||
});
|
||||
```
|
||||
|
||||
Le composant reçoit `{ params, segments }` :
|
||||
|
||||
| Prop | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `params` | `object` | Paramètres Next.js résolus (incluant `path`). |
|
||||
| `segments` | `string[]` | Segments d'URL après `/zen/<moduleName>/`. Le module fait son propre routage. |
|
||||
|
||||
Exemple : `/zen/billing/invoice/abc-123` → `segments = ['invoice', 'abc-123']`.
|
||||
|
||||
## Câblage côté projet consommateur
|
||||
|
||||
Le scaffolder `@zen/start` génère automatiquement `app/zen/[...path]/page.js` qui ré-exporte le composant serveur. Aucune action manuelle requise.
|
||||
|
||||
## Restrictions
|
||||
|
||||
- Le moduleName `api` est réservé et lève une exception à l'enregistrement.
|
||||
- Un seul composant par moduleName ; un appel ultérieur écrase le précédent.
|
||||
@@ -0,0 +1 @@
|
||||
export { registerPublicModulePage, getPublicModulePage, getPublicModulePages } from './registry.js';
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Registre runtime des pages publiques `/zen/<module>/<...>`.
|
||||
*
|
||||
* Chaque module externe enregistre un composant racine pour son namespace.
|
||||
* Le composant reçoit `{ params, segments }` où `segments` est le tableau
|
||||
* de chemins après `/zen/<module>/` ; le module fait son propre routage interne.
|
||||
*
|
||||
* Le préfixe `api` est réservé : tout enregistrement sous moduleName === 'api'
|
||||
* est rejeté pour éviter les collisions avec les routes API.
|
||||
*/
|
||||
|
||||
const REGISTRY_KEY = Symbol.for('__ZEN_PUBLIC_MODULE_PAGES__');
|
||||
if (!globalThis[REGISTRY_KEY]) globalThis[REGISTRY_KEY] = new Map();
|
||||
/** @type {Map<string, { moduleName: string, Component: any, title?: string }>} */
|
||||
const registry = globalThis[REGISTRY_KEY];
|
||||
|
||||
export function registerPublicModulePage({ moduleName, Component, title }) {
|
||||
if (typeof moduleName !== 'string' || !moduleName) {
|
||||
throw new TypeError('registerPublicModulePage: "moduleName" must be a non-empty string');
|
||||
}
|
||||
if (moduleName === 'api') {
|
||||
throw new Error('registerPublicModulePage: "api" is a reserved namespace under /zen/');
|
||||
}
|
||||
if (typeof Component !== 'function' && typeof Component !== 'object') {
|
||||
throw new TypeError(`registerPublicModulePage(${moduleName}): "Component" must be a React component`);
|
||||
}
|
||||
registry.set(moduleName, { moduleName, Component, title });
|
||||
}
|
||||
|
||||
export function getPublicModulePage(moduleName) {
|
||||
return registry.get(moduleName);
|
||||
}
|
||||
|
||||
export function getPublicModulePages() {
|
||||
return [...registry.values()];
|
||||
}
|
||||
+19
-10
@@ -2,6 +2,7 @@ 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';
|
||||
import { registerPermissions, getRegisteredPermissions } from './permissions-registry.js';
|
||||
|
||||
const USER_ROLE_PERMISSIONS = [];
|
||||
|
||||
@@ -81,11 +82,17 @@ async function migratePermissions() {
|
||||
}
|
||||
|
||||
async function seedDefaultRolesAndPermissions() {
|
||||
// Permissions
|
||||
for (const perm of PERMISSION_DEFINITIONS) {
|
||||
// S'assure que les permissions core sont dans le registre, puis seed depuis
|
||||
// le registre — qui contient core + permissions enregistrées par les modules.
|
||||
registerPermissions(PERMISSION_DEFINITIONS);
|
||||
const allPermissions = getRegisteredPermissions();
|
||||
|
||||
for (const perm of allPermissions) {
|
||||
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]
|
||||
`INSERT INTO zen_auth_permissions (key, name, description, group_name)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (key) DO UPDATE SET name = EXCLUDED.name, description = EXCLUDED.description, group_name = EXCLUDED.group_name`,
|
||||
[perm.key, perm.name, perm.description, perm.group_name]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -100,12 +107,14 @@ async function seedDefaultRolesAndPermissions() {
|
||||
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]
|
||||
);
|
||||
}
|
||||
// Toute permission présente dans le catalogue est attribuée au rôle admin —
|
||||
// y compris les permissions ajoutées par les modules après le premier init.
|
||||
await query(
|
||||
`INSERT INTO zen_auth_role_permissions (role_id, permission_key)
|
||||
SELECT $1, key FROM zen_auth_permissions
|
||||
ON CONFLICT DO NOTHING`,
|
||||
[adminId]
|
||||
);
|
||||
|
||||
// User role
|
||||
const userRoleId = generateId();
|
||||
|
||||
+11
-1
@@ -4,4 +4,14 @@ export { createSession, validateSession, deleteSession, deleteUserSessions, refr
|
||||
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';
|
||||
export {
|
||||
PERMISSIONS,
|
||||
PERMISSION_DEFINITIONS,
|
||||
getPermissionGroups,
|
||||
hasPermission,
|
||||
getUserPermissions,
|
||||
registerPermission,
|
||||
registerPermissions,
|
||||
getRegisteredPermissions,
|
||||
getRegisteredPermissionKeys,
|
||||
} from './permissions.js';
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Registre runtime des permissions.
|
||||
*
|
||||
* Le core enregistre ses permissions au boot (initializeZen) ; chaque module
|
||||
* externe enregistre les siennes via son hook register(). Le registre alimente
|
||||
* à la fois le seed BD (zen-db init) et la validation runtime (updateRole).
|
||||
*
|
||||
* Le registre est un singleton process-local persisté via Symbol.for sur
|
||||
* globalThis pour survivre aux hot-reloads Next.js.
|
||||
*/
|
||||
|
||||
const REGISTRY_KEY = Symbol.for('__ZEN_PERMISSIONS_REGISTRY__');
|
||||
if (!globalThis[REGISTRY_KEY]) globalThis[REGISTRY_KEY] = new Map();
|
||||
/** @type {Map<string, { key: string, name: string, description?: string, group_name: string }>} */
|
||||
const registry = globalThis[REGISTRY_KEY];
|
||||
|
||||
export function registerPermission({ key, name, description, group_name }) {
|
||||
if (typeof key !== 'string' || !key) {
|
||||
throw new TypeError('registerPermission: "key" must be a non-empty string');
|
||||
}
|
||||
if (typeof name !== 'string' || !name) {
|
||||
throw new TypeError(`registerPermission(${key}): "name" must be a non-empty string`);
|
||||
}
|
||||
if (typeof group_name !== 'string' || !group_name) {
|
||||
throw new TypeError(`registerPermission(${key}): "group_name" must be a non-empty string`);
|
||||
}
|
||||
registry.set(key, { key, name, description: description ?? null, group_name });
|
||||
}
|
||||
|
||||
export function registerPermissions(list) {
|
||||
if (!Array.isArray(list)) {
|
||||
throw new TypeError('registerPermissions: argument must be an array');
|
||||
}
|
||||
for (const perm of list) registerPermission(perm);
|
||||
}
|
||||
|
||||
export function getRegisteredPermissions() {
|
||||
return [...registry.values()];
|
||||
}
|
||||
|
||||
export function getRegisteredPermissionKeys() {
|
||||
return new Set(registry.keys());
|
||||
}
|
||||
|
||||
export function clearRegisteredPermissions() {
|
||||
registry.clear();
|
||||
}
|
||||
@@ -1,5 +1,11 @@
|
||||
import { query } from '@zen/core/database';
|
||||
export { PERMISSIONS, PERMISSION_DEFINITIONS, getPermissionGroups } from './constants.js';
|
||||
export {
|
||||
registerPermission,
|
||||
registerPermissions,
|
||||
getRegisteredPermissions,
|
||||
getRegisteredPermissionKeys,
|
||||
} from './permissions-registry.js';
|
||||
|
||||
export async function hasPermission(userId, permissionKey) {
|
||||
const result = await query(
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
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));
|
||||
import { getRegisteredPermissionKeys } from './permissions-registry.js';
|
||||
|
||||
export async function listRoles() {
|
||||
const result = await query(
|
||||
@@ -83,7 +81,8 @@ export async function updateRole(roleId, { name, description, color, permissionK
|
||||
);
|
||||
|
||||
if (!isSystem && permissionKeys !== undefined) {
|
||||
const safeKeys = [...new Set(permissionKeys)].filter(k => VALID_PERMISSION_KEYS.has(k));
|
||||
const validKeys = getRegisteredPermissionKeys();
|
||||
const safeKeys = [...new Set(permissionKeys)].filter(k => validKeys.has(k));
|
||||
await client.query(`DELETE FROM zen_auth_role_permissions WHERE role_id = $1`, [roleId]);
|
||||
for (const key of safeKeys) {
|
||||
await client.query(
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
Ce répertoire fournit l'interface d'administration complète : layout, navigation, tableau de bord, gestion des utilisateurs et des rôles. Il expose un **registre runtime** pour permettre aux projets consommateurs d'ajouter des widgets, des entrées de navigation et des pages sans modifier le core.
|
||||
|
||||
> Le pattern `zen.extensions.js` documenté ici reste valide pour les extensions in-projet (extensions ad hoc spécifiques à une app). Pour distribuer une extension réutilisable comme un package npm, consulter [docs/MODULES.md](../../../docs/MODULES.md) — la même API d'enregistrement s'utilise mais le module est auto-découvert via les `dependencies` du projet consommateur.
|
||||
|
||||
---
|
||||
|
||||
## Structure
|
||||
|
||||
@@ -3,9 +3,6 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Input, Textarea, Switch, Modal, ColorPicker } from '@zen/core/shared/components';
|
||||
import { useToast } from '@zen/core/toast';
|
||||
import { getPermissionGroups } from '@zen/core/users/constants';
|
||||
|
||||
const PERMISSION_GROUPS = getPermissionGroups();
|
||||
|
||||
const RoleEditModal = ({ roleId, isOpen, onClose, onSaved }) => {
|
||||
const toast = useToast();
|
||||
@@ -19,9 +16,12 @@ const RoleEditModal = ({ roleId, isOpen, onClose, onSaved }) => {
|
||||
const [description, setDescription] = useState('');
|
||||
const [color, setColor] = useState('#6b7280');
|
||||
const [selectedPerms, setSelectedPerms] = useState([]);
|
||||
// Catalogue dynamique des permissions (core + modules), récupéré via l'API.
|
||||
const [permissionGroups, setPermissionGroups] = useState({});
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
fetchPermissions();
|
||||
if (isNew) {
|
||||
setName('');
|
||||
setDescription('');
|
||||
@@ -33,6 +33,18 @@ const RoleEditModal = ({ roleId, isOpen, onClose, onSaved }) => {
|
||||
fetchRole();
|
||||
}, [isOpen, roleId]);
|
||||
|
||||
const fetchPermissions = async () => {
|
||||
try {
|
||||
const response = await fetch('/zen/api/permissions', { credentials: 'include' });
|
||||
if (!response.ok) return;
|
||||
const data = await response.json();
|
||||
setPermissionGroups(data.groups || {});
|
||||
} catch {
|
||||
// Si le catalogue n'est pas joignable, on laisse l'utilisateur sauvegarder
|
||||
// ses changements ; les permissions invalides sont filtrées côté serveur.
|
||||
}
|
||||
};
|
||||
|
||||
const fetchRole = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
@@ -146,7 +158,7 @@ const RoleEditModal = ({ roleId, isOpen, onClose, onSaved }) => {
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-500 dark:text-neutral-400">Permissions</p>
|
||||
{Object.entries(PERMISSION_GROUPS).map(([group, perms]) => (
|
||||
{Object.entries(permissionGroups).map(([group, perms]) => (
|
||||
<div key={group} className="rounded-xl border border-neutral-200 dark:border-neutral-700/60 overflow-hidden">
|
||||
<div className="px-4 py-2.5 bg-neutral-50 dark:bg-neutral-800/60 border-b border-neutral-200 dark:border-neutral-700/60">
|
||||
<p className="text-[11px] font-medium font-ibm-plex-mono text-neutral-500 dark:text-neutral-400 uppercase tracking-wide">
|
||||
|
||||
@@ -12,7 +12,7 @@ import { updateUser, requestPasswordReset } from './auth.js';
|
||||
import { hashPassword, verifyPassword, generateId } from '../../core/users/password.js';
|
||||
import { createEmailChangeToken, verifyEmailChangeToken, applyEmailChange, sendEmailChangeConfirmEmail, sendEmailChangeOldNotifyEmail, sendEmailChangeNewNotifyEmail, sendPasswordChangedEmail, sendPasswordResetEmail, sendInvitationEmail } from './email.js';
|
||||
import { createAccountSetup } from '../../core/users/verifications.js';
|
||||
import { listRoles, getRoleById, createRole, updateRole, deleteRole, getUserRoles, assignUserRole, revokeUserRole, deleteUserSessions, PERMISSIONS } from '@zen/core/users';
|
||||
import { listRoles, getRoleById, createRole, updateRole, deleteRole, getUserRoles, assignUserRole, revokeUserRole, deleteUserSessions, PERMISSIONS, getRegisteredPermissions } from '@zen/core/users';
|
||||
import { uploadImage, deleteFile, generateUniqueFilename, getFileExtension, FILE_TYPE_PRESETS, FILE_SIZE_LIMITS, validateUpload } from '@zen/core/storage';
|
||||
import { getPublicBaseUrl } from '@zen/core/shared/config';
|
||||
|
||||
@@ -563,6 +563,21 @@ async function handleListRoles() {
|
||||
return apiSuccess({ roles });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /zen/api/permissions (admin only)
|
||||
// Catalogue dynamique : core + permissions enregistrées par les modules.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function handleListPermissions() {
|
||||
const permissions = getRegisteredPermissions();
|
||||
const groups = permissions.reduce((acc, perm) => {
|
||||
if (!acc[perm.group_name]) acc[perm.group_name] = [];
|
||||
acc[perm.group_name].push(perm);
|
||||
return acc;
|
||||
}, {});
|
||||
return apiSuccess({ permissions, groups });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /zen/api/roles (admin only)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -934,6 +949,7 @@ export const routes = defineApiRoutes([
|
||||
{ path: '/users/:id/password', method: 'PUT', handler: handleAdminSetUserPassword, auth: 'admin', permission: PERMISSIONS.USERS_MANAGE },
|
||||
{ path: '/users/:id/send-password-reset', method: 'POST', handler: handleAdminSendPasswordReset, auth: 'admin', permission: PERMISSIONS.USERS_MANAGE },
|
||||
{ path: '/roles', method: 'GET', handler: handleListRoles, auth: 'admin', permission: PERMISSIONS.ROLES_VIEW },
|
||||
{ path: '/permissions', method: 'GET', handler: handleListPermissions, auth: 'admin', permission: PERMISSIONS.ROLES_VIEW },
|
||||
{ path: '/roles', method: 'POST', handler: handleCreateRole, auth: 'admin', permission: PERMISSIONS.ROLES_MANAGE },
|
||||
{ path: '/roles/:id', method: 'GET', handler: handleGetRole, auth: 'admin', permission: PERMISSIONS.ROLES_VIEW },
|
||||
{ path: '/roles/:id', method: 'PUT', handler: handleUpdateRole, auth: 'admin', permission: PERMISSIONS.ROLES_MANAGE },
|
||||
|
||||
+59
-7
@@ -1,25 +1,66 @@
|
||||
/**
|
||||
* Core Feature Database Initialization (CLI)
|
||||
* Database initialization for features and modules.
|
||||
*
|
||||
* Initialise et supprime les tables des features core. La liste est aujourd'hui
|
||||
* limitée à auth — le seul feature avec un schéma. Ajouter ici si un autre
|
||||
* feature gagne un db.js avec createTables()/dropTables().
|
||||
* - Features core : auth (et tout futur core ayant un db.js).
|
||||
* - Modules externes : découverts via discoverModules() ; chaque module
|
||||
* exporte ses propres createTables/dropTables.
|
||||
*
|
||||
* Les permissions ajoutées par les modules doivent être enregistrées AVANT
|
||||
* le seed de la BD pour qu'elles soient persistées et auto-attribuées au
|
||||
* rôle admin. C'est pour cela qu'on appelle register() de chaque module
|
||||
* avant initFeatures().
|
||||
*/
|
||||
|
||||
import { createTables as authCreate, dropTables as authDrop } from './auth/db.js';
|
||||
import { done, fail, info, step } from '@zen/core/shared/logger';
|
||||
import { discoverModules, validateModuleEnvVars } from '../core/modules/discover.server.js';
|
||||
import { getRegisteredModules } from '../core/modules/registry.js';
|
||||
import { registerPermissions } from '../core/users/permissions-registry.js';
|
||||
|
||||
const FEATURES = [
|
||||
const CORE_FEATURES = [
|
||||
{ name: 'auth', createTables: authCreate, dropTables: authDrop },
|
||||
];
|
||||
|
||||
async function loadModules() {
|
||||
await discoverModules();
|
||||
const modules = getRegisteredModules();
|
||||
validateModuleEnvVars(modules);
|
||||
|
||||
// Enregistre les permissions du module et exécute son register() pour que
|
||||
// tous les hooks runtime soient en place avant le seed.
|
||||
for (const mod of modules) {
|
||||
if (Array.isArray(mod.manifest?.permissions)) {
|
||||
registerPermissions(mod.manifest.permissions);
|
||||
}
|
||||
if (typeof mod.register === 'function') {
|
||||
try {
|
||||
await mod.register();
|
||||
} catch (error) {
|
||||
fail(`zen-modules: ${mod.manifest.name} register() threw — ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return modules;
|
||||
}
|
||||
|
||||
export async function initFeatures() {
|
||||
const created = [];
|
||||
const skipped = [];
|
||||
|
||||
step('Initializing feature databases...');
|
||||
|
||||
for (const { name, createTables } of FEATURES) {
|
||||
// Charger les modules d'abord pour que leurs permissions soient connues
|
||||
// au moment du seed (et donc auto-attribuées au rôle admin).
|
||||
const modules = await loadModules();
|
||||
|
||||
const targets = [
|
||||
...CORE_FEATURES,
|
||||
...modules
|
||||
.filter(m => typeof m.createTables === 'function')
|
||||
.map(m => ({ name: m.manifest.name, createTables: m.createTables, dropTables: m.dropTables })),
|
||||
];
|
||||
|
||||
for (const { name, createTables } of targets) {
|
||||
try {
|
||||
step(`Initializing ${name}...`);
|
||||
if (typeof createTables !== 'function') {
|
||||
@@ -40,7 +81,18 @@ export async function initFeatures() {
|
||||
}
|
||||
|
||||
export async function dropFeatures() {
|
||||
for (const { name, dropTables } of [...FEATURES].reverse()) {
|
||||
const modules = await loadModules();
|
||||
|
||||
// Ordre de création : core, puis modules. Drop = ordre inverse pour que
|
||||
// les tables modules (qui peuvent avoir des FK vers core) tombent d'abord.
|
||||
const targets = [
|
||||
...CORE_FEATURES,
|
||||
...modules
|
||||
.filter(m => typeof m.dropTables === 'function')
|
||||
.map(m => ({ name: m.manifest.name, dropTables: m.dropTables })),
|
||||
];
|
||||
|
||||
for (const { name, dropTables } of [...targets].reverse()) {
|
||||
try {
|
||||
if (typeof dropTables !== 'function') {
|
||||
info(`${name} has no dropTables function`);
|
||||
|
||||
+23
-1
@@ -19,7 +19,11 @@ import { registerStoragePolicies, clearStorageConfig } from '@zen/core/storage';
|
||||
import { validateSession } from '../../features/auth/session.js';
|
||||
import { routes as authRoutes } from '../../features/auth/api.js';
|
||||
import { storageAccessPolicies } from '../../features/auth/storage-policies.js';
|
||||
import { done, warn } from './logger.js';
|
||||
import { PERMISSION_DEFINITIONS } from '../../core/users/constants.js';
|
||||
import { registerPermissions, clearRegisteredPermissions } from '../../core/users/permissions-registry.js';
|
||||
import { discoverModules, validateModuleEnvVars } from '../../core/modules/discover.server.js';
|
||||
import { getRegisteredModules, clearRegisteredModules } from '../../core/modules/registry.js';
|
||||
import { done, warn, fail } from './logger.js';
|
||||
|
||||
const ZEN_INIT_KEY = Symbol.for('__ZEN_INITIALIZED__');
|
||||
|
||||
@@ -38,6 +42,22 @@ export async function initializeZen() {
|
||||
configureRouter({ resolveSession: validateSession });
|
||||
registerFeatureRoutes(authRoutes);
|
||||
registerStoragePolicies(storageAccessPolicies);
|
||||
registerPermissions(PERMISSION_DEFINITIONS);
|
||||
|
||||
// Découverte et activation des modules @zen/module-*
|
||||
await discoverModules();
|
||||
const modules = getRegisteredModules();
|
||||
validateModuleEnvVars(modules);
|
||||
for (const mod of modules) {
|
||||
if (Array.isArray(mod.manifest?.permissions)) {
|
||||
registerPermissions(mod.manifest.permissions);
|
||||
}
|
||||
try {
|
||||
await mod.register();
|
||||
} catch (error) {
|
||||
fail(`zen-modules: ${mod.manifest.name} register() threw — ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
done('ZEN: ready');
|
||||
|
||||
@@ -49,5 +69,7 @@ export function resetZenInitialization() {
|
||||
clearRouterConfig();
|
||||
clearFeatureRoutes();
|
||||
clearStorageConfig();
|
||||
clearRegisteredPermissions();
|
||||
clearRegisteredModules();
|
||||
warn('ZEN: initialization reset');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user