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:
2026-04-25 10:50:13 -04:00
parent 3098940905
commit a3aff9fa49
23 changed files with 776 additions and 33 deletions
+2
View File
@@ -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">
+17 -1
View File
@@ -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
View File
@@ -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`);