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:
@@ -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`);
|
||||
|
||||
Reference in New Issue
Block a user