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