feat(core)!: introduce runtime extension registry and flat module conventions

BREAKING CHANGE: sup config now derives entries from package.json#exports and a server/client glob instead of manual lists; module structure follows flat + barrel convention with .server.js/.client.js runtime suffixes
This commit is contained in:
2026-04-22 14:13:30 -04:00
parent 61388f04a6
commit 0106bc4ea0
35 changed files with 917 additions and 528 deletions
+34 -8
View File
@@ -1,11 +1,37 @@
'use client';
/**
* Admin Pages Export for Next.js App Router
*
* This exports the admin client components.
* Users must create their own server component wrapper that uses protectAdmin.
*/
import { getPage } from './registry.js';
import './pages/index.client.js';
import './widgets/index.client.js';
export { default as AdminPagesClient } from './components/AdminPages.js';
export { default as AdminPagesLayout } from './components/AdminPagesLayout.js';
export default function AdminPageClient({ params, user, widgetData }) {
const parts = params?.admin || [];
const [first, second, third] = parts;
// Routes paramétrées — le registre stocke le composant sous un slug
// "namespace:form", le client y attache les bons props.
if (first === 'users' && second === 'edit' && third) {
const page = getPage('users:edit');
if (page) return <page.Component userId={third} user={user} />;
}
if (first === 'roles' && second === 'edit' && third) {
const page = getPage('roles:edit');
if (page) return <page.Component roleId={third} user={user} />;
}
if (first === 'roles' && second === 'new') {
const page = getPage('roles:edit');
if (page) return <page.Component roleId="new" user={user} />;
}
const slug = first || 'dashboard';
const page = getPage(slug) || getPage('dashboard');
if (!page) return null;
const { Component } = page;
// Le tableau de bord reçoit les données collectées côté serveur ; les
// autres pages ne connaissent pas le widget data.
if (slug === 'dashboard') {
return <Component user={user} stats={widgetData} />;
}
return <Component user={user} params={parts} />;
}
+11 -17
View File
@@ -1,38 +1,32 @@
/**
* Admin Page - Server Component Wrapper for Next.js App Router
*
* Re-export this in your app/admin/[...admin]/page.js:
* export { default } from '@zen/core/features/admin/page';
*/
import { AdminPagesLayout, AdminPagesClient } from '@zen/core/features/admin/pages';
import { protectAdmin } from '@zen/core/features/admin';
import { buildNavigationSections } from '@zen/core/features/admin/navigation';
import { collectAllDashboardData } from './dashboard/serverRegistry.js';
import AdminShell from './components/AdminShell.js';
import AdminPageClient from './AdminPage.client.js';
import { protectAdmin } from './protect.js';
import { buildNavigationSections } from './navigation.js';
import { collectWidgetData } from './registry.js';
import { logoutAction } from '@zen/core/features/auth/actions';
import { getAppName } from '@zen/core';
import './widgets/index.server.js';
export default async function AdminPage({ params }) {
const resolvedParams = await params;
const session = await protectAdmin();
const appName = getAppName();
const dashboardStats = await collectAllDashboardData();
const widgetData = await collectWidgetData();
const navigationSections = buildNavigationSections('/');
return (
<AdminPagesLayout
<AdminShell
user={session.user}
onLogout={logoutAction}
appName={appName}
navigationSections={navigationSections}
>
<AdminPagesClient
<AdminPageClient
params={resolvedParams}
user={session.user}
dashboardStats={dashboardStats}
widgetData={widgetData}
/>
</AdminPagesLayout>
</AdminShell>
);
}
+11 -5
View File
@@ -1,10 +1,10 @@
'use client';
import AdminSidebar from './AdminSidebar';
import { useState } from 'react';
import AdminHeader from './AdminHeader';
import AdminSidebar from './AdminSidebar.js';
import AdminHeader from './AdminHeader.js';
export default function AdminPagesLayout({ children, user, onLogout, appName, enabledModules, navigationSections }) {
export default function AdminShell({ children, user, onLogout, appName, navigationSections }) {
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
return (
@@ -13,11 +13,17 @@ export default function AdminPagesLayout({ children, user, onLogout, appName, en
isMobileMenuOpen={isMobileMenuOpen}
setIsMobileMenuOpen={setIsMobileMenuOpen}
appName={appName}
enabledModules={enabledModules}
navigationSections={navigationSections}
/>
<div className="flex-1 flex flex-col min-w-0">
<AdminHeader isMobileMenuOpen={isMobileMenuOpen} setIsMobileMenuOpen={setIsMobileMenuOpen} user={user} onLogout={onLogout} appName={appName} navigationSections={navigationSections} />
<AdminHeader
isMobileMenuOpen={isMobileMenuOpen}
setIsMobileMenuOpen={setIsMobileMenuOpen}
user={user}
onLogout={onLogout}
appName={appName}
navigationSections={navigationSections}
/>
<main className="flex-1 overflow-y-auto bg-neutral-50 dark:bg-black">
<div className="px-8 py-7 pb-32 max-w-[1920px] mx-auto">
{children}
+5 -5
View File
@@ -1,6 +1,6 @@
/**
* Admin Components Exports
*/
'use client';
export { default as AdminPagesClient } from './AdminPages.js';
export { default as AdminPagesLayout } from './AdminPagesLayout.js';
export { default as AdminShell } from './AdminShell.js';
export { default as AdminSidebar } from './AdminSidebar.js';
export { default as AdminHeader } from './AdminHeader.js';
export { default as ThemeToggle } from './ThemeToggle.js';
+24 -13
View File
@@ -1,16 +1,27 @@
/**
* Zen Admin Module
* Admin panel functionality with role-based access control
* Zen Admin — barrel serveur.
*
* - Gardes d'accès : protectAdmin, isAdmin.
* - Navigation : buildNavigationSections.
* - Registre d'extensions : registerWidget, registerWidgetFetcher, registerNavItem,
* registerNavSection, registerPage (import une seule fois depuis le layout
* racine de l'app consommatrice pour que les side effects s'exécutent).
*
* Client components sous @zen/core/features/admin/components.
*/
// Middleware exports
export { protectAdmin, isAdmin } from './middleware/protect.js';
// Component exports (for catch-all routes)
export { AdminPagesClient, AdminPagesLayout } from './pages.js';
// NOTE: Server-only navigation builder is in '@zen/core/admin/navigation'
// Do NOT import from this file to avoid bundling database code into client
// NOTE: Admin server actions are exported separately to avoid bundling issues
// Import them from '@zen/core/admin/actions' instead
export { protectAdmin, isAdmin } from './protect.js';
export { buildNavigationSections } from './navigation.js';
export {
registerWidget,
registerWidgetFetcher,
registerNavItem,
registerNavSection,
registerPage,
collectWidgetData,
getWidgets,
getNavItems,
getNavSections,
getPage,
getPages,
} from './registry.js';
+39
View File
@@ -0,0 +1,39 @@
import {
registerNavSection,
registerNavItem,
getNavSections,
getNavItems,
} from './registry.js';
// Sections et items core — enregistrés à l'import de ce module.
registerNavSection({ id: 'dashboard', title: 'Tableau de bord', icon: 'DashboardSquare03Icon', order: 10 });
registerNavSection({ id: 'system', title: 'Utilisateurs', icon: 'UserMultiple02Icon', order: 20 });
registerNavItem({ id: 'dashboard', label: 'Tableau de bord', icon: 'DashboardSquare03Icon', href: '/admin/dashboard', sectionId: 'dashboard', order: 10 });
registerNavItem({ id: 'users', label: 'Utilisateurs', icon: 'UserMultiple02Icon', href: '/admin/users', sectionId: 'system', order: 10 });
registerNavItem({ id: 'roles', label: 'Rôles', icon: 'Crown03Icon', href: '/admin/roles', sectionId: 'system', order: 20 });
/**
* Build sections for AdminSidebar. Items are sérialisables (pas de composants),
* icônes en chaînes résolues côté client.
*/
export function buildNavigationSections(pathname) {
const sections = getNavSections();
const items = getNavItems();
const bySection = new Map();
for (const item of items) {
const list = bySection.get(item.sectionId) || [];
list.push({
name: item.label,
href: item.href,
icon: item.icon,
current: pathname === item.href || pathname.startsWith(item.href + '/'),
});
bySection.set(item.sectionId, list);
}
return sections
.filter(s => bySection.has(s.id))
.map(s => ({ id: s.id, title: s.title, icon: s.icon, items: bySection.get(s.id) }));
}
@@ -1,33 +1,20 @@
'use client';
import { getClientWidgets } from '../../dashboard/clientRegistry.js';
import '../../dashboard/widgets/index.client.js';
import '../../../dashboard.client.js';
// Évalué après tous les imports : les auto-registrations sont complètes
const sortedWidgets = getClientWidgets();
import { getWidgets } from '../registry.js';
export default function DashboardPage({ stats }) {
const loading = stats === null || stats === undefined;
const widgets = getWidgets();
return (
<div className="flex flex-col gap-4 sm:gap-6 lg:gap-8">
<div className="flex items-center justify-between">
<div>
<h1 className="text-lg sm:text-xl font-semibold text-neutral-900 dark:text-white">
Tableau de bord
</h1>
<p className="mt-1 text-[13px] text-neutral-500 dark:text-neutral-400">Vue d'ensemble de votre application</p>
</div>
<div>
<h1 className="text-lg sm:text-xl font-semibold text-neutral-900 dark:text-white">Tableau de bord</h1>
<p className="mt-1 text-[13px] text-neutral-500 dark:text-neutral-400">Vue d'ensemble de votre application</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 gap-4 sm:gap-6">
{sortedWidgets.map(({ id, Component }) => (
<Component
key={id}
data={loading ? null : (stats[id] ?? null)}
loading={loading}
/>
{widgets.map(({ id, Component }) => (
<Component key={id} data={loading ? null : (stats[id] ?? null)} loading={loading} />
))}
</div>
</div>
+19
View File
@@ -0,0 +1,19 @@
'use client';
import { registerPage } from '../registry.js';
import DashboardPage from './DashboardPage.client.js';
import UsersPage from './UsersPage.client.js';
import UserEditPage from './UserEditPage.client.js';
import RolesPage from './RolesPage.client.js';
import RoleEditPage from './RoleEditPage.client.js';
import ProfilePage from './ProfilePage.client.js';
// Pages core — le slug correspond au premier segment après /admin/. Les
// routes paramétrées (users/edit/:id, roles/edit/:id, roles/new) sont
// résolues dans AdminPage.client.js via le slug "namespace:form".
registerPage({ slug: 'dashboard', Component: DashboardPage, title: 'Tableau de bord' });
registerPage({ slug: 'users', Component: UsersPage, title: 'Utilisateurs' });
registerPage({ slug: 'roles', Component: RolesPage, title: 'Rôles' });
registerPage({ slug: 'profile', Component: ProfilePage, title: 'Profil' });
registerPage({ slug: 'users:edit', Component: UserEditPage, title: 'Modifier utilisateur' });
registerPage({ slug: 'roles:edit', Component: RoleEditPage, title: 'Modifier rôle' });
+4 -21
View File
@@ -2,35 +2,18 @@ import { getSession } from '@zen/core/features/auth/actions';
import { hasPermission, PERMISSIONS } from '@zen/core/users';
import { redirect } from 'next/navigation';
/**
* Protect an admin page - requires authentication and admin.access permission.
* Use this in server components to require admin access.
*/
async function protectAdmin(options = {}) {
const { redirectTo = '/auth/login', forbiddenRedirect = '/' } = options;
export async function protectAdmin({ redirectTo = '/auth/login', forbiddenRedirect = '/' } = {}) {
const session = await getSession();
if (!session) {
redirect(redirectTo);
}
if (!session) redirect(redirectTo);
const allowed = await hasPermission(session.user.id, PERMISSIONS.ADMIN_ACCESS);
if (!allowed) {
redirect(forbiddenRedirect);
}
if (!allowed) redirect(forbiddenRedirect);
return session;
}
/**
* Check if the current user has admin.access permission.
* Non-redirecting check for conditional rendering.
*/
async function isAdmin() {
export async function isAdmin() {
const session = await getSession();
if (!session) return false;
return hasPermission(session.user.id, PERMISSIONS.ADMIN_ACCESS);
}
export { protectAdmin, isAdmin };
+84
View File
@@ -0,0 +1,84 @@
/**
* Registre unique pour étendre l'admin sans modifier le core.
*
* Trois types d'extensions :
* - widget : une tuile du tableau de bord. Côté serveur on enregistre un fetcher
* (registerWidgetFetcher), côté client le Composant (registerWidget).
* - navItem : une entrée de la sidebar admin (section optionnelle pour grouper).
* - page : un composant rendu sous /admin/<slug>.
*
* Les instances de module sont séparées entre le bundle serveur et le bundle
* client de Next.js ; c'est attendu : les fetchers vivent côté serveur, les
* Composants côté client. Les navItems et les pages sont enregistrés côté
* neutre et visibles des deux côtés.
*/
const widgetFetchers = new Map(); // id -> async () => data
const widgetComponents = new Map(); // id -> { Component, order }
const navItems = new Map(); // id -> { id, label, icon, href, order, sectionId }
const navSections = new Map(); // id -> { id, title, icon, order }
const pages = new Map(); // slug -> { slug, Component, title? }
// ---- Widgets ---------------------------------------------------------------
export function registerWidgetFetcher(id, fetcher) {
widgetFetchers.set(id, fetcher);
}
export function registerWidget({ id, Component, order = 0 }) {
widgetComponents.set(id, { Component, order });
}
export function getWidgets() {
return [...widgetComponents.entries()]
.map(([id, v]) => ({ id, ...v }))
.sort((a, b) => a.order - b.order);
}
// Un fetcher qui échoue n'empêche pas les autres de produire leur donnée.
export async function collectWidgetData() {
const entries = [...widgetFetchers.entries()];
const results = await Promise.allSettled(
entries.map(async ([id, fetch]) => [id, await fetch()])
);
const out = {};
for (const r of results) {
if (r.status === 'fulfilled') {
const [id, data] = r.value;
out[id] = data;
}
}
return out;
}
// ---- Navigation ------------------------------------------------------------
export function registerNavSection({ id, title, icon, order = 0 }) {
navSections.set(id, { id, title, icon, order });
}
export function registerNavItem({ id, label, icon, href, order = 0, sectionId = 'main' }) {
navItems.set(id, { id, label, icon, href, order, sectionId });
}
export function getNavSections() {
return [...navSections.values()].sort((a, b) => a.order - b.order);
}
export function getNavItems() {
return [...navItems.values()].sort((a, b) => a.order - b.order);
}
// ---- Pages -----------------------------------------------------------------
export function registerPage({ slug, Component, title }) {
pages.set(slug, { slug, Component, title });
}
export function getPage(slug) {
return pages.get(slug);
}
export function getPages() {
return [...pages.values()];
}
@@ -0,0 +1,5 @@
'use client';
// Import side-effects : chaque widget core s'auto-enregistre auprès du registry.
// Ajouter un widget core = créer un nouveau fichier *.client.js et l'importer ici.
import './users.client.js';
@@ -0,0 +1,3 @@
// Import side-effects : chaque widget core s'auto-enregistre auprès du registry.
// Ajouter un widget core = créer un nouveau fichier *.server.js et l'importer ici.
import './users.server.js';
+3 -16
View File
@@ -1,21 +1,10 @@
'use client';
/**
* Widget core — Composant client : nombre d'utilisateurs
*
* Auto-enregistré via admin/dashboard/widgets/index.client.js.
* Pas besoin de modifier features/dashboard.client.js pour ce widget.
*
* Props du composant :
* data — { totalUsers: number, newThisMonth: number } retourné par getUsersDashboardData(), ou null
* loading — true tant que les données serveur ne sont pas disponibles
*/
import { registerClientWidget } from '../clientRegistry.js';
import { registerWidget } from '../registry.js';
import { StatCard } from '@zen/core/shared/components';
import { UserMultiple02Icon } from '@zen/core/shared/icons';
function UsersDashboardWidget({ data, loading }) {
function UsersWidget({ data, loading }) {
const newThisMonth = data?.newThisMonth ?? 0;
return (
<StatCard
@@ -31,6 +20,4 @@ function UsersDashboardWidget({ data, loading }) {
);
}
registerClientWidget('users', UsersDashboardWidget, 10);
export default UsersDashboardWidget;
registerWidget({ id: 'users', Component: UsersWidget, order: 10 });
+4 -13
View File
@@ -1,15 +1,8 @@
/**
* Widget core — Contribution serveur : nombre d'utilisateurs
*
* Auto-enregistré via admin/dashboard/widgets/index.server.js.
* Pas besoin de modifier features/dashboard.server.js pour ce widget.
*/
import { registerServerWidget } from '../registry.js';
import { registerWidgetFetcher } from '../registry.js';
import { query } from '@zen/core/database';
import { fail } from '@zen/core/shared/logger';
async function getUsersDashboardData() {
registerWidgetFetcher('users', async () => {
try {
const result = await query(`
SELECT
@@ -22,9 +15,7 @@ async function getUsersDashboardData() {
newThisMonth: parseInt(result.rows[0].new_this_month) || 0,
};
} catch (error) {
fail(`Users dashboard data error: ${error.message}`);
fail(`Users widget data error: ${error.message}`);
return { totalUsers: 0, newThisMonth: 0 };
}
}
registerServerWidget('users', getUsersDashboardData);
});