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:
@@ -12,6 +12,8 @@ Pour la procédure de publication du package : [PUBLICATION.md](./dev/PUBLICATIO
|
|||||||
|
|
||||||
Pour les conventions de commit : [COMMITS.md](./dev/COMMITS.md).
|
Pour les conventions de commit : [COMMITS.md](./dev/COMMITS.md).
|
||||||
|
|
||||||
|
Pour la création de modules externes `@zen/module-*` : [MODULES.md](./MODULES.md).
|
||||||
|
|
||||||
> **Contexte projet** : les utilisateurs finaux créent leur projet via `npx @zen/start`, qui génère automatiquement une structure Next.js avec la plateforme déjà intégré. Lors d'une assistance ou d'une revue, partez du principe que le projet cible est déjà structuré de cette façon.
|
> **Contexte projet** : les utilisateurs finaux créent leur projet via `npx @zen/start`, qui génère automatiquement une structure Next.js avec la plateforme déjà intégré. Lors d'une assistance ou d'une revue, partez du principe que le projet cible est déjà structuré de cette façon.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -71,6 +73,8 @@ Ces suffixes ne sont pas cosmétiques : **le build les utilise comme source de v
|
|||||||
|
|
||||||
L'admin utilise un **registre runtime** pour permettre aux projets consommateurs d'ajouter des widgets, des entrées de navigation, et des pages sans modifier le core.
|
L'admin utilise un **registre runtime** pour permettre aux projets consommateurs d'ajouter des widgets, des entrées de navigation, et des pages sans modifier le core.
|
||||||
|
|
||||||
|
> Pour distribuer ces extensions sous forme de package npm réutilisable plutôt que de les écrire en local, voir [MODULES.md](./MODULES.md) — chaque module `@zen/module-*` installé est auto-découvert et activé.
|
||||||
|
|
||||||
```js
|
```js
|
||||||
// app/zen.extensions.js — projet consommateur
|
// app/zen.extensions.js — projet consommateur
|
||||||
import {
|
import {
|
||||||
|
|||||||
+248
@@ -0,0 +1,248 @@
|
|||||||
|
# Modules externes `@zen/module-*`
|
||||||
|
|
||||||
|
Un **module** est un package npm distinct qui ajoute des fonctionnalités à un projet construit avec `@zen/core` — sans aucune modification de code dans le projet consommateur.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install @zen/module-billing
|
||||||
|
# ajouter les variables d'env documentées dans le README du module
|
||||||
|
npx zen-db init # crée les tables du module et seed ses permissions
|
||||||
|
npm run dev # tout est câblé : pages admin, sidebar, widgets, API, /zen/<module>/...
|
||||||
|
```
|
||||||
|
|
||||||
|
Aucun fichier de configuration manuelle. La plateforme découvre les modules par scan des dépendances `package.json`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Découverte
|
||||||
|
|
||||||
|
Au boot et au lancement de `zen-db init`, le core scanne `dependencies` + `devDependencies` du `package.json` du projet consommateur et charge tout package matchant :
|
||||||
|
|
||||||
|
- **Préfixe officiel** : `@zen/module-*`
|
||||||
|
- **Préfixe non-scopé** : `zen-module-*`
|
||||||
|
- **Tiers** : tout package dont le `package.json` contient `"keywords": ["zen-module"]`
|
||||||
|
|
||||||
|
Pour chaque module trouvé, le core vérifie qu'il exporte les bons symboles, puis l'enregistre.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Forme d'un module
|
||||||
|
|
||||||
|
Le point d'entrée du package (`main` ou `exports["."]`) doit exporter :
|
||||||
|
|
||||||
|
```js
|
||||||
|
// @zen/module-blog/index.js
|
||||||
|
|
||||||
|
export const manifest = {
|
||||||
|
name: '@zen/module-blog',
|
||||||
|
version: '1.0.0',
|
||||||
|
permissions: [
|
||||||
|
{ key: 'blog.view', name: 'Voir les billets', description: 'Consultation', group_name: 'Blog' },
|
||||||
|
{ key: 'blog.manage', name: 'Gérer les billets', description: 'CRUD', group_name: 'Blog' },
|
||||||
|
],
|
||||||
|
envVars: [
|
||||||
|
{ key: 'BLOG_UPLOAD_DIR', required: false, description: 'Répertoire des médias' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function register() {
|
||||||
|
// Tous les enregistrements runtime se font ici (voir API ci-dessous).
|
||||||
|
await import('./register-server.js');
|
||||||
|
}
|
||||||
|
|
||||||
|
export { createTables, dropTables } from './db.js';
|
||||||
|
```
|
||||||
|
|
||||||
|
| Export | Type | Obligatoire |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| `manifest` | objet (voir ci-dessous) | oui |
|
||||||
|
| `register` | `() => void \| Promise<void>` | oui |
|
||||||
|
| `createTables` | `async () => { created?: string[], skipped?: string[] }` | si le module a des tables |
|
||||||
|
| `dropTables` | `async () => void` | si le module a des tables |
|
||||||
|
|
||||||
|
### Manifest
|
||||||
|
|
||||||
|
| Champ | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| `name` | `string` | Nom du package (utilisé comme identifiant unique). |
|
||||||
|
| `version` | `string` | Version du module (logguée au boot). |
|
||||||
|
| `permissions` | `Array` | Permissions ajoutées au catalogue. Auto-attribuées au rôle `admin` au prochain `zen-db init`. |
|
||||||
|
| `envVars` | `Array` | Variables d'env du module ; les `required: true` absentes émettent un warning au boot. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API d'enregistrement
|
||||||
|
|
||||||
|
Toutes ces fonctions s'utilisent depuis le hook `register()` du module.
|
||||||
|
|
||||||
|
### Permissions
|
||||||
|
|
||||||
|
Déclarées dans `manifest.permissions`. Le core les enregistre automatiquement avant le seed BD et les attribue au rôle `admin`. À la connexion, l'admin peut les distribuer à d'autres rôles via `/admin/roles`.
|
||||||
|
|
||||||
|
### Sidebar admin
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { registerNavSection, registerNavItem } from '@zen/core/features/admin';
|
||||||
|
|
||||||
|
registerNavSection({ id: 'blog', title: 'Blog', icon: 'Notebook01Icon', order: 40 });
|
||||||
|
registerNavItem({
|
||||||
|
id: 'blog-posts',
|
||||||
|
label: 'Billets',
|
||||||
|
icon: 'Notebook01Icon',
|
||||||
|
href: '/admin/blog',
|
||||||
|
sectionId: 'blog',
|
||||||
|
permission: 'blog.view',
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pages admin
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { registerPage } from '@zen/core/features/admin';
|
||||||
|
import BlogAdminPage from './admin/BlogAdminPage.client.js';
|
||||||
|
|
||||||
|
registerPage({ slug: 'blog', Component: BlogAdminPage, title: 'Blog' });
|
||||||
|
```
|
||||||
|
|
||||||
|
Rendue sous `/admin/blog`.
|
||||||
|
|
||||||
|
### Widgets dashboard
|
||||||
|
|
||||||
|
```js
|
||||||
|
// côté serveur
|
||||||
|
import { registerWidgetFetcher } from '@zen/core/features/admin';
|
||||||
|
registerWidgetFetcher('blog-posts', async () => ({ count: await countPosts() }));
|
||||||
|
|
||||||
|
// côté client
|
||||||
|
'use client';
|
||||||
|
import { registerWidget } from '@zen/core/features/admin';
|
||||||
|
registerWidget({ id: 'blog-posts', Component: BlogWidget, order: 40, permission: 'blog.view' });
|
||||||
|
```
|
||||||
|
|
||||||
|
### Routes API
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { registerApiRoutes } from '@zen/core/api';
|
||||||
|
import { defineApiRoutes, apiSuccess } from '@zen/core/api';
|
||||||
|
|
||||||
|
const routes = defineApiRoutes([
|
||||||
|
{ path: '/blog/posts', method: 'GET', handler: handleListPosts, auth: 'admin', permission: 'blog.view' },
|
||||||
|
{ path: '/blog/posts', method: 'POST', handler: handleCreatePost, auth: 'admin', permission: 'blog.manage' },
|
||||||
|
{ path: '/blog/posts/:id', method: 'GET', handler: handleGetPost, auth: 'public' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
registerApiRoutes(routes);
|
||||||
|
```
|
||||||
|
|
||||||
|
Le router applique automatiquement la session, le rate-limit et la vérification de permission. Les routes sont accessibles sous `/zen/api/*`.
|
||||||
|
|
||||||
|
| Champ `auth` | Comportement |
|
||||||
|
|--------------|--------------|
|
||||||
|
| `'public'` | Aucune session requise. |
|
||||||
|
| `'user'` | Session valide. |
|
||||||
|
| `'admin'` | Session avec permission `admin.access`. La permission granulaire `permission` est aussi vérifiée si fournie. |
|
||||||
|
|
||||||
|
### Pages publiques `/zen/<module>/...`
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { registerPublicModulePage } from '@zen/core/public-pages';
|
||||||
|
import BlogPublicPage from './public/BlogPublicPage.js';
|
||||||
|
|
||||||
|
registerPublicModulePage({ moduleName: 'blog', Component: BlogPublicPage });
|
||||||
|
```
|
||||||
|
|
||||||
|
URL : `/zen/blog/<...>`. Le composant reçoit `{ params, segments }` :
|
||||||
|
|
||||||
|
```js
|
||||||
|
function BlogPublicPage({ params, segments }) {
|
||||||
|
// /zen/blog/post/abc-123 → segments = ['post', 'abc-123']
|
||||||
|
if (segments[0] === 'post') return <PostView id={segments[1]} />;
|
||||||
|
return <BlogIndex />;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Le namespace `api` est réservé aux routes API et ne peut être utilisé comme `moduleName`.
|
||||||
|
|
||||||
|
### Migrations BD
|
||||||
|
|
||||||
|
```js
|
||||||
|
// db.js
|
||||||
|
import { query, tableExists } from '@zen/core/database';
|
||||||
|
|
||||||
|
const TABLES = [
|
||||||
|
{ name: 'zen_blog_posts', sql: `CREATE TABLE zen_blog_posts (...)` },
|
||||||
|
];
|
||||||
|
|
||||||
|
export async function createTables() {
|
||||||
|
const created = [];
|
||||||
|
const skipped = [];
|
||||||
|
for (const t of TABLES) {
|
||||||
|
if (await tableExists(t.name)) { skipped.push(t.name); continue; }
|
||||||
|
await query(t.sql);
|
||||||
|
created.push(t.name);
|
||||||
|
}
|
||||||
|
return { created, skipped };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function dropTables() {
|
||||||
|
for (const t of [...TABLES].reverse()) {
|
||||||
|
await query(`DROP TABLE IF EXISTS "${t.name}" CASCADE`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Convention : préfixer toutes les tables par `zen_<module>_` pour éviter les collisions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Frontières serveur/client
|
||||||
|
|
||||||
|
Comme pour le core (voir [DEV.md](DEV.md)), les fichiers du module portent les suffixes `.server.js` / `.client.js`. Le hook `register()` côté serveur est appelé par le core ; les enregistrements client (widgets, par ex.) doivent être triggés par un import dans le bundle client — typiquement via le composant client lui-même qui appelle `registerWidget()` à l'import.
|
||||||
|
|
||||||
|
```js
|
||||||
|
// Pattern recommandé pour un module avec partie client :
|
||||||
|
|
||||||
|
// src/register-server.js (importé par register())
|
||||||
|
import './admin/BlogAdminPage.client.js'; // chaîne d'imports vers client
|
||||||
|
import './widgets/BlogWidget.server.js';
|
||||||
|
// ... registerNavItem, registerPage, registerApiRoutes, etc.
|
||||||
|
```
|
||||||
|
|
||||||
|
Le bundle Next.js du projet consommateur traverse le graphe d'import et inclut les composants client dans le bundle client. Côté serveur, seules les fonctions et fetchers serveur sont chargés.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Variables d'environnement
|
||||||
|
|
||||||
|
Toute variable requise par le module doit être déclarée dans `manifest.envVars` et documentée dans le `README.md` du module. Les variables `required: true` absentes génèrent un warning au boot — elles ne crashent pas le serveur, le module gère son propre fallback.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Squelette minimal d'un module
|
||||||
|
|
||||||
|
```
|
||||||
|
@zen/module-blog/
|
||||||
|
├── package.json # name: "@zen/module-blog", main: "./index.js"
|
||||||
|
├── README.md # documente les env vars et la configuration
|
||||||
|
├── index.js # exporte manifest, register, createTables, dropTables
|
||||||
|
├── db.js # createTables/dropTables
|
||||||
|
├── register-server.js # imports déclencheurs (chargé par register())
|
||||||
|
├── api.js # routes API (registerApiRoutes)
|
||||||
|
├── admin/
|
||||||
|
│ ├── BlogAdminPage.client.js # registerPage + composant
|
||||||
|
│ └── widgets/... # registerWidgetFetcher + registerWidget
|
||||||
|
└── public/
|
||||||
|
└── BlogPublicPage.js # registerPublicModulePage
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cycle de vie complet
|
||||||
|
|
||||||
|
| Étape | Côté core | Côté module |
|
||||||
|
|-------|-----------|-------------|
|
||||||
|
| Install | — | `npm install @zen/module-X` |
|
||||||
|
| Configuration | — | Ajout des `envVars` au `.env` |
|
||||||
|
| Migration BD | `zen-db init` scanne, charge le module, registerPermissions(), seed, createTables() | `createTables()` exécuté |
|
||||||
|
| Boot serveur | `instrumentation.js` → `initializeZen()` scanne, charge, registerPermissions(), `register()` | `register()` exécuté côté serveur |
|
||||||
|
| Premier render client | bundle client traverse les imports → composants client enregistrés | `registerWidget()` exécuté côté client |
|
||||||
|
| Runtime | router dispatch les requêtes, admin résout les pages/widgets via le registre | aucun travail supplémentaire |
|
||||||
@@ -93,6 +93,15 @@
|
|||||||
"./users/constants": {
|
"./users/constants": {
|
||||||
"import": "./dist/core/users/constants.js"
|
"import": "./dist/core/users/constants.js"
|
||||||
},
|
},
|
||||||
|
"./modules": {
|
||||||
|
"import": "./dist/core/modules/index.js"
|
||||||
|
},
|
||||||
|
"./public-pages": {
|
||||||
|
"import": "./dist/core/public-pages/index.js"
|
||||||
|
},
|
||||||
|
"./public-pages/server": {
|
||||||
|
"import": "./dist/core/public-pages/PublicModulePage.server.js"
|
||||||
|
},
|
||||||
"./api": {
|
"./api": {
|
||||||
"import": "./dist/core/api/index.js"
|
"import": "./dist/core/api/index.js"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
export { routeRequest, requireAuth, requireAdmin } from './router.js';
|
export { routeRequest, requireAuth, requireAdmin } from './router.js';
|
||||||
|
|
||||||
// Runtime state — session resolver + feature routes registry
|
// 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)
|
// Response utilities — use in all handlers (core and modules)
|
||||||
export { apiSuccess, apiError, getStatusCode } from './respond.js';
|
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];
|
const _featureRoutes = globalThis[REGISTRY_KEY];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Enregistre les routes d'une feature core.
|
* Enregistre des routes API.
|
||||||
* Appelé une fois par feature pendant initializeZen().
|
* 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()
|
* @param {ReadonlyArray} routes - Définitions produites par defineApiRoutes()
|
||||||
*/
|
*/
|
||||||
export function registerFeatureRoutes(routes) {
|
export function registerApiRoutes(routes) {
|
||||||
if (!Array.isArray(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);
|
_featureRoutes.push(...routes);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alias rétro-compatible de registerApiRoutes.
|
||||||
|
* @deprecated Utiliser registerApiRoutes.
|
||||||
|
*/
|
||||||
|
export const registerFeatureRoutes = registerApiRoutes;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retourne toutes les routes de features enregistrées.
|
* Retourne toutes les routes de features enregistrées.
|
||||||
* Appelé à chaque requête par le router pour construire la liste complète.
|
* 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()];
|
||||||
|
}
|
||||||
+17
-8
@@ -2,6 +2,7 @@ import { query, tableExists } from '@zen/core/database';
|
|||||||
import { generateId } from './password.js';
|
import { generateId } from './password.js';
|
||||||
import { done, warn } from '@zen/core/shared/logger';
|
import { done, warn } from '@zen/core/shared/logger';
|
||||||
import { PERMISSION_DEFINITIONS } from './constants.js';
|
import { PERMISSION_DEFINITIONS } from './constants.js';
|
||||||
|
import { registerPermissions, getRegisteredPermissions } from './permissions-registry.js';
|
||||||
|
|
||||||
const USER_ROLE_PERMISSIONS = [];
|
const USER_ROLE_PERMISSIONS = [];
|
||||||
|
|
||||||
@@ -81,11 +82,17 @@ async function migratePermissions() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function seedDefaultRolesAndPermissions() {
|
async function seedDefaultRolesAndPermissions() {
|
||||||
// Permissions
|
// S'assure que les permissions core sont dans le registre, puis seed depuis
|
||||||
for (const perm of PERMISSION_DEFINITIONS) {
|
// le registre — qui contient core + permissions enregistrées par les modules.
|
||||||
|
registerPermissions(PERMISSION_DEFINITIONS);
|
||||||
|
const allPermissions = getRegisteredPermissions();
|
||||||
|
|
||||||
|
for (const perm of allPermissions) {
|
||||||
await query(
|
await query(
|
||||||
`INSERT INTO zen_auth_permissions (key, name, group_name) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING`,
|
`INSERT INTO zen_auth_permissions (key, name, description, group_name)
|
||||||
[perm.key, perm.name, perm.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 adminRole = await query(`SELECT id FROM zen_auth_roles WHERE name = 'admin'`);
|
||||||
const adminId = adminRole.rows[0].id;
|
const adminId = adminRole.rows[0].id;
|
||||||
|
|
||||||
for (const perm of PERMISSION_DEFINITIONS) {
|
// 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(
|
await query(
|
||||||
`INSERT INTO zen_auth_role_permissions (role_id, permission_key) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
|
`INSERT INTO zen_auth_role_permissions (role_id, permission_key)
|
||||||
[adminId, perm.key]
|
SELECT $1, key FROM zen_auth_permissions
|
||||||
|
ON CONFLICT DO NOTHING`,
|
||||||
|
[adminId]
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
// User role
|
// User role
|
||||||
const userRoleId = generateId();
|
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 { createEmailVerification, verifyEmailToken, createPasswordReset, verifyResetToken, deleteResetToken } from './verifications.js';
|
||||||
export { hashPassword, verifyPassword, generateToken, generateId } from './password.js';
|
export { hashPassword, verifyPassword, generateToken, generateId } from './password.js';
|
||||||
export { listRoles, getRoleById, createRole, updateRole, deleteRole, getUserRoles, assignUserRole, revokeUserRole } from './roles.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';
|
import { query } from '@zen/core/database';
|
||||||
export { PERMISSIONS, PERMISSION_DEFINITIONS, getPermissionGroups } from './constants.js';
|
export { PERMISSIONS, PERMISSION_DEFINITIONS, getPermissionGroups } from './constants.js';
|
||||||
|
export {
|
||||||
|
registerPermission,
|
||||||
|
registerPermissions,
|
||||||
|
getRegisteredPermissions,
|
||||||
|
getRegisteredPermissionKeys,
|
||||||
|
} from './permissions-registry.js';
|
||||||
|
|
||||||
export async function hasPermission(userId, permissionKey) {
|
export async function hasPermission(userId, permissionKey) {
|
||||||
const result = await query(
|
const result = await query(
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { query, transaction } from '@zen/core/database';
|
import { query, transaction } from '@zen/core/database';
|
||||||
import { generateId } from './password.js';
|
import { generateId } from './password.js';
|
||||||
import { PERMISSIONS } from './permissions.js';
|
import { getRegisteredPermissionKeys } from './permissions-registry.js';
|
||||||
|
|
||||||
const VALID_PERMISSION_KEYS = new Set(Object.values(PERMISSIONS));
|
|
||||||
|
|
||||||
export async function listRoles() {
|
export async function listRoles() {
|
||||||
const result = await query(
|
const result = await query(
|
||||||
@@ -83,7 +81,8 @@ export async function updateRole(roleId, { name, description, color, permissionK
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (!isSystem && permissionKeys !== undefined) {
|
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]);
|
await client.query(`DELETE FROM zen_auth_role_permissions WHERE role_id = $1`, [roleId]);
|
||||||
for (const key of safeKeys) {
|
for (const key of safeKeys) {
|
||||||
await client.query(
|
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.
|
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
|
## Structure
|
||||||
|
|||||||
@@ -3,9 +3,6 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Input, Textarea, Switch, Modal, ColorPicker } from '@zen/core/shared/components';
|
import { Input, Textarea, Switch, Modal, ColorPicker } from '@zen/core/shared/components';
|
||||||
import { useToast } from '@zen/core/toast';
|
import { useToast } from '@zen/core/toast';
|
||||||
import { getPermissionGroups } from '@zen/core/users/constants';
|
|
||||||
|
|
||||||
const PERMISSION_GROUPS = getPermissionGroups();
|
|
||||||
|
|
||||||
const RoleEditModal = ({ roleId, isOpen, onClose, onSaved }) => {
|
const RoleEditModal = ({ roleId, isOpen, onClose, onSaved }) => {
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
@@ -19,9 +16,12 @@ const RoleEditModal = ({ roleId, isOpen, onClose, onSaved }) => {
|
|||||||
const [description, setDescription] = useState('');
|
const [description, setDescription] = useState('');
|
||||||
const [color, setColor] = useState('#6b7280');
|
const [color, setColor] = useState('#6b7280');
|
||||||
const [selectedPerms, setSelectedPerms] = useState([]);
|
const [selectedPerms, setSelectedPerms] = useState([]);
|
||||||
|
// Catalogue dynamique des permissions (core + modules), récupéré via l'API.
|
||||||
|
const [permissionGroups, setPermissionGroups] = useState({});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) return;
|
if (!isOpen) return;
|
||||||
|
fetchPermissions();
|
||||||
if (isNew) {
|
if (isNew) {
|
||||||
setName('');
|
setName('');
|
||||||
setDescription('');
|
setDescription('');
|
||||||
@@ -33,6 +33,18 @@ const RoleEditModal = ({ roleId, isOpen, onClose, onSaved }) => {
|
|||||||
fetchRole();
|
fetchRole();
|
||||||
}, [isOpen, roleId]);
|
}, [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 () => {
|
const fetchRole = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -146,7 +158,7 @@ const RoleEditModal = ({ roleId, isOpen, onClose, onSaved }) => {
|
|||||||
|
|
||||||
<div className="flex flex-col gap-3">
|
<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>
|
<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 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">
|
<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">
|
<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 { hashPassword, verifyPassword, generateId } from '../../core/users/password.js';
|
||||||
import { createEmailChangeToken, verifyEmailChangeToken, applyEmailChange, sendEmailChangeConfirmEmail, sendEmailChangeOldNotifyEmail, sendEmailChangeNewNotifyEmail, sendPasswordChangedEmail, sendPasswordResetEmail, sendInvitationEmail } from './email.js';
|
import { createEmailChangeToken, verifyEmailChangeToken, applyEmailChange, sendEmailChangeConfirmEmail, sendEmailChangeOldNotifyEmail, sendEmailChangeNewNotifyEmail, sendPasswordChangedEmail, sendPasswordResetEmail, sendInvitationEmail } from './email.js';
|
||||||
import { createAccountSetup } from '../../core/users/verifications.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 { uploadImage, deleteFile, generateUniqueFilename, getFileExtension, FILE_TYPE_PRESETS, FILE_SIZE_LIMITS, validateUpload } from '@zen/core/storage';
|
||||||
import { getPublicBaseUrl } from '@zen/core/shared/config';
|
import { getPublicBaseUrl } from '@zen/core/shared/config';
|
||||||
|
|
||||||
@@ -563,6 +563,21 @@ async function handleListRoles() {
|
|||||||
return apiSuccess({ roles });
|
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)
|
// 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/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: '/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: '/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', 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: 'GET', handler: handleGetRole, auth: 'admin', permission: PERMISSIONS.ROLES_VIEW },
|
||||||
{ path: '/roles/:id', method: 'PUT', handler: handleUpdateRole, auth: 'admin', permission: PERMISSIONS.ROLES_MANAGE },
|
{ 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
|
* - Features core : auth (et tout futur core ayant un db.js).
|
||||||
* limitée à auth — le seul feature avec un schéma. Ajouter ici si un autre
|
* - Modules externes : découverts via discoverModules() ; chaque module
|
||||||
* feature gagne un db.js avec createTables()/dropTables().
|
* 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 { createTables as authCreate, dropTables as authDrop } from './auth/db.js';
|
||||||
import { done, fail, info, step } from '@zen/core/shared/logger';
|
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 },
|
{ 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() {
|
export async function initFeatures() {
|
||||||
const created = [];
|
const created = [];
|
||||||
const skipped = [];
|
const skipped = [];
|
||||||
|
|
||||||
step('Initializing feature databases...');
|
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 {
|
try {
|
||||||
step(`Initializing ${name}...`);
|
step(`Initializing ${name}...`);
|
||||||
if (typeof createTables !== 'function') {
|
if (typeof createTables !== 'function') {
|
||||||
@@ -40,7 +81,18 @@ export async function initFeatures() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function dropFeatures() {
|
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 {
|
try {
|
||||||
if (typeof dropTables !== 'function') {
|
if (typeof dropTables !== 'function') {
|
||||||
info(`${name} has no 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 { validateSession } from '../../features/auth/session.js';
|
||||||
import { routes as authRoutes } from '../../features/auth/api.js';
|
import { routes as authRoutes } from '../../features/auth/api.js';
|
||||||
import { storageAccessPolicies } from '../../features/auth/storage-policies.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__');
|
const ZEN_INIT_KEY = Symbol.for('__ZEN_INITIALIZED__');
|
||||||
|
|
||||||
@@ -38,6 +42,22 @@ export async function initializeZen() {
|
|||||||
configureRouter({ resolveSession: validateSession });
|
configureRouter({ resolveSession: validateSession });
|
||||||
registerFeatureRoutes(authRoutes);
|
registerFeatureRoutes(authRoutes);
|
||||||
registerStoragePolicies(storageAccessPolicies);
|
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');
|
done('ZEN: ready');
|
||||||
|
|
||||||
@@ -49,5 +69,7 @@ export function resetZenInitialization() {
|
|||||||
clearRouterConfig();
|
clearRouterConfig();
|
||||||
clearFeatureRoutes();
|
clearFeatureRoutes();
|
||||||
clearStorageConfig();
|
clearStorageConfig();
|
||||||
|
clearRegisteredPermissions();
|
||||||
|
clearRegisteredModules();
|
||||||
warn('ZEN: initialization reset');
|
warn('ZEN: initialization reset');
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user