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
+60 -9
View File
@@ -36,22 +36,73 @@ Pour les conventions de commit : [COMMITS.md](./dev/COMMITS.md).
--- ---
## Conventions d'arborescence
Une feature (`src/features/<nom>/` ou `src/core/<nom>/`) suit la règle **flat + un barrel** :
un `index.js` qui ré-exporte, des fichiers plats côte à côte pour l'implémentation. Pas de sous-dossier `lib/`, `middleware/`, `actions/` quand il ne contient qu'un ou deux fichiers — remonter directement au niveau du dossier feature.
Les sous-dossiers sont autorisés uniquement quand ils contiennent plusieurs fichiers du même rôle : `components/`, `pages/`, `templates/`, `widgets/`.
### Suffixes de runtime
Tout fichier épinglé à une frontière Next.js porte le suffixe dans son nom :
- `.server.js` → code serveur strict (peut importer `pg`, `fs`, etc.)
- `.client.js` → débute par `'use client'`
- pas de suffixe → module neutre, utilisable des deux côtés
- `actions.js` → débute par `'use server'` (server actions Next.js)
Ces suffixes ne sont pas cosmétiques : **le build les utilise comme source de vérité**. Tout fichier `*.server.js` ou `*.client.js` est automatiquement ajouté à la config tsup non-bundlée pour préserver la frontière RSC.
---
## Build et configuration tsup ## Build et configuration tsup
### Règle des externals ### Source de vérité
Tout import de la forme `@zen/core/*` dans un fichier bundlé par tsup (typiquement `src/modules/*/api.js`, `src/modules/*/actions.js`, `src/modules/*/crud.js`) **doit figurer dans la liste `external`** du premier bloc de config dans `tsup.config.js`. `tsup.config.js` dérive ses entrées de deux sources :
Pourquoi : tsup tente de résoudre ces imports au moment du build. Or les fichiers `dist/` n'existent pas encore — le build échoue avec `Could not resolve "@zen/core/..."`. 1. `package.json#exports` — pour tous les points d'entrée publics.
2. Un glob récursif `src/**/*.{server,client}.js` — pour le wiring interne (pages, widgets) qui doit rester en module séparé sans être public.
**Règle :** quand on crée ou refactorise un module `src/core/*/index.js` exposé via `package.json` `exports`, on ajoute immédiatement l'entrée correspondante dans `external` de `tsup.config.js`. **Ajouter un module public = éditer seulement `package.json#exports`.** La liste `external` et la liste `entry` sont régénérées au prochain build. Il n'y a plus trois listes à synchroniser.
Les self-imports `@zen/core/*` sont générés automatiquement à partir des clés de `exports`.
---
## Étendre l'admin
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.
```js ```js
// tsup.config.js — external (premier bloc) // app/zen.extensions.js — projet consommateur
'@zen/core/api', // ← à ajouter si src/core/api/index.js est un entry tsup import {
'@zen/core/database', registerWidget,
'@zen/core/storage', registerWidgetFetcher,
// etc. registerNavItem,
registerNavSection,
registerPage,
} from '@zen/core/features/admin';
import OrdersWidget from './admin/OrdersWidget';
import OrdersPage from './admin/OrdersPage';
import { countOrders } from './admin/orders.server';
// Widget dashboard — fetcher serveur + composant client partagent un même id.
registerWidgetFetcher('orders', async () => ({ total: await countOrders() }));
registerWidget({ id: 'orders', Component: OrdersWidget, order: 20 });
// Sidebar
registerNavSection({ id: 'commerce', title: 'Commerce', icon: 'ShoppingBag03Icon', order: 30 });
registerNavItem({ id: 'orders', label: 'Commandes', icon: 'ShoppingBag03Icon', href: '/admin/orders', sectionId: 'commerce' });
// Page — le slug correspond au segment sous /admin/.
registerPage({ slug: 'orders', Component: OrdersPage, title: 'Commandes' });
```
```js
// app/layout.js — un seul import suffit ; les side effects enregistrent tout.
import './zen.extensions';
``` ```
--- ---
+2 -1
View File
@@ -228,8 +228,9 @@ L'interface d'administration centrale. Tableau de bord visuel pour gérer le sit
**Ce qu'il fait :** **Ce qu'il fait :**
- Protection des routes admin : `protectAdmin()`, `isAdmin()` - Protection des routes admin : `protectAdmin()`, `isAdmin()`
- Pages catch-all pour l'interface admin (`AdminPagesClient`, `AdminPagesLayout`) - Pages catch-all pour l'interface admin (`AdminPage.server.js`, `AdminPage.client.js`, `AdminShell`)
- Navigation construite côté serveur (`buildNavigationSections`) - Navigation construite côté serveur (`buildNavigationSections`)
- Registre d'extensions runtime pour widgets, nav items et pages (`registerWidget`, `registerNavItem`, `registerPage`)
- Gestion des utilisateurs depuis l'interface - Gestion des utilisateurs depuis l'interface
**Navigation :** **Navigation :**
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "@zen/core", "name": "@zen/core",
"version": "1.3.47", "version": "1.4.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@zen/core", "name": "@zen/core",
"version": "1.3.47", "version": "1.4.1",
"license": "GPL-3.0-only", "license": "GPL-3.0-only",
"dependencies": { "dependencies": {
"@headlessui/react": "^2.0.0", "@headlessui/react": "^2.0.0",
+33 -100
View File
@@ -1,6 +1,6 @@
{ {
"name": "@zen/core", "name": "@zen/core",
"version": "1.3.47", "version": "1.4.1",
"description": "Un CMS Next.js construit sur l'essentiel, rien de plus, rien de moins.", "description": "Un CMS Next.js construit sur l'essentiel, rien de plus, rien de moins.",
"repository": { "repository": {
"type": "git", "type": "git",
@@ -48,104 +48,37 @@
"react": "^19.0.0" "react": "^19.0.0"
}, },
"exports": { "exports": {
".": { ".": { "import": "./dist/index.js" },
"import": "./dist/index.js" "./features/auth": { "import": "./dist/features/auth/index.js" },
}, "./features/auth/actions": { "import": "./dist/features/auth/actions.js" },
"./features/auth": { "./features/auth/server": { "import": "./dist/features/auth/AuthPage.server.js" },
"import": "./dist/features/auth/index.js" "./features/auth/client": { "import": "./dist/features/auth/AuthPage.client.js" },
}, "./features/auth/components": { "import": "./dist/features/auth/components/index.js" },
"./features/auth/actions": { "./features/admin": { "import": "./dist/features/admin/index.js" },
"import": "./dist/features/auth/actions.js" "./features/admin/server": { "import": "./dist/features/admin/AdminPage.server.js" },
}, "./features/admin/client": { "import": "./dist/features/admin/AdminPage.client.js" },
"./features/auth/pages": { "./features/admin/components": { "import": "./dist/features/admin/components/index.js" },
"import": "./dist/features/auth/pages.js" "./features/provider": { "import": "./dist/features/provider/index.js" },
}, "./users": { "import": "./dist/core/users/index.js" },
"./features/auth/page": { "./users/constants": { "import": "./dist/core/users/constants.js" },
"import": "./dist/features/auth/page.js" "./api": { "import": "./dist/core/api/index.js" },
}, "./api/handler": { "import": "./dist/core/api/route-handler.js" },
"./features/auth/components": { "./database": { "import": "./dist/core/database/index.js" },
"import": "./dist/features/auth/components/index.js" "./storage": { "import": "./dist/core/storage/index.js" },
}, "./email": { "import": "./dist/core/email/index.js" },
"./features/admin": { "./email/templates": { "import": "./dist/core/email/templates/index.js" },
"import": "./dist/features/admin/index.js" "./cron": { "import": "./dist/core/cron/index.js" },
}, "./stripe": { "import": "./dist/core/payments/stripe.js" },
"./features/admin/actions": { "./payments": { "import": "./dist/core/payments/index.js" },
"import": "./dist/features/admin/actions.js" "./pdf": { "import": "./dist/core/pdf/index.js" },
}, "./toast": { "import": "./dist/core/toast/index.js" },
"./features/admin/navigation": { "./themes": { "import": "./dist/core/themes/index.js" },
"import": "./dist/features/admin/navigation.server.js" "./shared/components": { "import": "./dist/shared/components/index.js" },
}, "./shared/icons": { "import": "./dist/shared/icons/index.js" },
"./features/admin/pages": { "./shared/metadata": { "import": "./dist/shared/lib/metadata/index.js" },
"import": "./dist/features/admin/pages.js" "./shared/logger": { "import": "./dist/shared/lib/logger.js" },
}, "./shared/config": { "import": "./dist/shared/lib/appConfig.js" },
"./features/admin/page": { "./shared/rate-limit": { "import": "./dist/shared/lib/rateLimit.js" },
"import": "./dist/features/admin/page.js" "./styles/zen.css": { "default": "./dist/shared/styles/zen.css" }
},
"./features/provider": {
"import": "./dist/features/provider/index.js"
},
"./users": {
"import": "./dist/core/users/index.js"
},
"./users/constants": {
"import": "./dist/core/users/constants.js"
},
"./api": {
"import": "./dist/core/api/index.js"
},
"./zen/api": {
"import": "./dist/core/api/route-handler.js"
},
"./database": {
"import": "./dist/core/database/index.js"
},
"./storage": {
"import": "./dist/core/storage/index.js"
},
"./email": {
"import": "./dist/core/email/index.js"
},
"./email/templates": {
"import": "./dist/core/email/templates/index.js"
},
"./cron": {
"import": "./dist/core/cron/index.js"
},
"./stripe": {
"import": "./dist/core/payments/stripe.js"
},
"./payments": {
"import": "./dist/core/payments/index.js"
},
"./pdf": {
"import": "./dist/core/pdf/index.js"
},
"./toast": {
"import": "./dist/core/toast/index.js"
},
"./themes": {
"import": "./dist/core/themes/index.js"
},
"./shared/components": {
"import": "./dist/shared/components/index.js"
},
"./shared/icons": {
"import": "./dist/shared/Icons.js"
},
"./shared/lib/metadata": {
"import": "./dist/shared/lib/metadata/index.js"
},
"./shared/logger": {
"import": "./dist/shared/lib/logger.js"
},
"./shared/config": {
"import": "./dist/shared/lib/appConfig.js"
},
"./shared/rate-limit": {
"import": "./dist/shared/lib/rateLimit.js"
},
"./styles/zen.css": {
"default": "./dist/shared/styles/zen.css"
}
} }
} }
+34 -8
View File
@@ -1,11 +1,37 @@
'use client'; 'use client';
/** import { getPage } from './registry.js';
* Admin Pages Export for Next.js App Router import './pages/index.client.js';
* import './widgets/index.client.js';
* This exports the admin client components.
* Users must create their own server component wrapper that uses protectAdmin.
*/
export { default as AdminPagesClient } from './components/AdminPages.js'; export default function AdminPageClient({ params, user, widgetData }) {
export { default as AdminPagesLayout } from './components/AdminPagesLayout.js'; 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 @@
/** import AdminShell from './components/AdminShell.js';
* Admin Page - Server Component Wrapper for Next.js App Router import AdminPageClient from './AdminPage.client.js';
* import { protectAdmin } from './protect.js';
* Re-export this in your app/admin/[...admin]/page.js: import { buildNavigationSections } from './navigation.js';
* export { default } from '@zen/core/features/admin/page'; import { collectWidgetData } from './registry.js';
*/
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 { logoutAction } from '@zen/core/features/auth/actions'; import { logoutAction } from '@zen/core/features/auth/actions';
import { getAppName } from '@zen/core'; import { getAppName } from '@zen/core';
import './widgets/index.server.js';
export default async function AdminPage({ params }) { export default async function AdminPage({ params }) {
const resolvedParams = await params; const resolvedParams = await params;
const session = await protectAdmin(); const session = await protectAdmin();
const appName = getAppName(); const appName = getAppName();
const dashboardStats = await collectAllDashboardData(); const widgetData = await collectWidgetData();
const navigationSections = buildNavigationSections('/'); const navigationSections = buildNavigationSections('/');
return ( return (
<AdminPagesLayout <AdminShell
user={session.user} user={session.user}
onLogout={logoutAction} onLogout={logoutAction}
appName={appName} appName={appName}
navigationSections={navigationSections} navigationSections={navigationSections}
> >
<AdminPagesClient <AdminPageClient
params={resolvedParams} params={resolvedParams}
user={session.user} user={session.user}
dashboardStats={dashboardStats} widgetData={widgetData}
/> />
</AdminPagesLayout> </AdminShell>
); );
} }
+11 -5
View File
@@ -1,10 +1,10 @@
'use client'; 'use client';
import AdminSidebar from './AdminSidebar';
import { useState } from 'react'; 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); const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
return ( return (
@@ -13,11 +13,17 @@ export default function AdminPagesLayout({ children, user, onLogout, appName, en
isMobileMenuOpen={isMobileMenuOpen} isMobileMenuOpen={isMobileMenuOpen}
setIsMobileMenuOpen={setIsMobileMenuOpen} setIsMobileMenuOpen={setIsMobileMenuOpen}
appName={appName} appName={appName}
enabledModules={enabledModules}
navigationSections={navigationSections} navigationSections={navigationSections}
/> />
<div className="flex-1 flex flex-col min-w-0"> <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"> <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"> <div className="px-8 py-7 pb-32 max-w-[1920px] mx-auto">
{children} {children}
+5 -5
View File
@@ -1,6 +1,6 @@
/** 'use client';
* Admin Components Exports
*/
export { default as AdminPagesClient } from './AdminPages.js'; export { default as AdminShell } from './AdminShell.js';
export { default as AdminPagesLayout } from './AdminPagesLayout.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 * Zen Admin — barrel serveur.
* Admin panel functionality with role-based access control *
* - 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 './protect.js';
export { protectAdmin, isAdmin } from './middleware/protect.js'; export { buildNavigationSections } from './navigation.js';
export {
// Component exports (for catch-all routes) registerWidget,
export { AdminPagesClient, AdminPagesLayout } from './pages.js'; registerWidgetFetcher,
registerNavItem,
// NOTE: Server-only navigation builder is in '@zen/core/admin/navigation' registerNavSection,
// Do NOT import from this file to avoid bundling database code into client registerPage,
collectWidgetData,
// NOTE: Admin server actions are exported separately to avoid bundling issues getWidgets,
// Import them from '@zen/core/admin/actions' instead 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'; 'use client';
import { getClientWidgets } from '../../dashboard/clientRegistry.js'; import { getWidgets } from '../registry.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();
export default function DashboardPage({ stats }) { export default function DashboardPage({ stats }) {
const loading = stats === null || stats === undefined; const loading = stats === null || stats === undefined;
const widgets = getWidgets();
return ( return (
<div className="flex flex-col gap-4 sm:gap-6 lg:gap-8"> <div className="flex flex-col gap-4 sm:gap-6 lg:gap-8">
<div className="flex items-center justify-between"> <div>
<div> <h1 className="text-lg sm:text-xl font-semibold text-neutral-900 dark:text-white">Tableau de bord</h1>
<h1 className="text-lg sm:text-xl font-semibold text-neutral-900 dark:text-white"> <p className="mt-1 text-[13px] text-neutral-500 dark:text-neutral-400">Vue d'ensemble de votre application</p>
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> </div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 gap-4 sm:gap-6"> <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 }) => ( {widgets.map(({ id, Component }) => (
<Component <Component key={id} data={loading ? null : (stats[id] ?? null)} loading={loading} />
key={id}
data={loading ? null : (stats[id] ?? null)}
loading={loading}
/>
))} ))}
</div> </div>
</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 { hasPermission, PERMISSIONS } from '@zen/core/users';
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
/** export async function protectAdmin({ redirectTo = '/auth/login', forbiddenRedirect = '/' } = {}) {
* 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;
const session = await getSession(); const session = await getSession();
if (!session) redirect(redirectTo);
if (!session) {
redirect(redirectTo);
}
const allowed = await hasPermission(session.user.id, PERMISSIONS.ADMIN_ACCESS); const allowed = await hasPermission(session.user.id, PERMISSIONS.ADMIN_ACCESS);
if (!allowed) { if (!allowed) redirect(forbiddenRedirect);
redirect(forbiddenRedirect);
}
return session; return session;
} }
/** export async function isAdmin() {
* Check if the current user has admin.access permission.
* Non-redirecting check for conditional rendering.
*/
async function isAdmin() {
const session = await getSession(); const session = await getSession();
if (!session) return false; if (!session) return false;
return hasPermission(session.user.id, PERMISSIONS.ADMIN_ACCESS); 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'; 'use client';
/** import { registerWidget } from '../registry.js';
* 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 { StatCard } from '@zen/core/shared/components'; import { StatCard } from '@zen/core/shared/components';
import { UserMultiple02Icon } from '@zen/core/shared/icons'; import { UserMultiple02Icon } from '@zen/core/shared/icons';
function UsersDashboardWidget({ data, loading }) { function UsersWidget({ data, loading }) {
const newThisMonth = data?.newThisMonth ?? 0; const newThisMonth = data?.newThisMonth ?? 0;
return ( return (
<StatCard <StatCard
@@ -31,6 +20,4 @@ function UsersDashboardWidget({ data, loading }) {
); );
} }
registerClientWidget('users', UsersDashboardWidget, 10); registerWidget({ id: 'users', Component: UsersWidget, order: 10 });
export default UsersDashboardWidget;
+4 -13
View File
@@ -1,15 +1,8 @@
/** import { registerWidgetFetcher } from '../registry.js';
* 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 { query } from '@zen/core/database'; import { query } from '@zen/core/database';
import { fail } from '@zen/core/shared/logger'; import { fail } from '@zen/core/shared/logger';
async function getUsersDashboardData() { registerWidgetFetcher('users', async () => {
try { try {
const result = await query(` const result = await query(`
SELECT SELECT
@@ -22,9 +15,7 @@ async function getUsersDashboardData() {
newThisMonth: parseInt(result.rows[0].new_this_month) || 0, newThisMonth: parseInt(result.rows[0].new_this_month) || 0,
}; };
} catch (error) { } catch (error) {
fail(`Users dashboard data error: ${error.message}`); fail(`Users widget data error: ${error.message}`);
return { totalUsers: 0, newThisMonth: 0 }; return { totalUsers: 0, newThisMonth: 0 };
} }
} });
registerServerWidget('users', getUsersDashboardData);
+85 -8
View File
@@ -1,12 +1,89 @@
'use client'; 'use client';
/** import { useRouter } from 'next/navigation';
* Auth Pages Export for Next.js App Router import { useEffect, useState } from 'react';
* import LoginPage from './pages/LoginPage.client.js';
* This exports the auth client components. import RegisterPage from './pages/RegisterPage.client.js';
* Users must create their own server component wrapper that imports the actions. import ForgotPasswordPage from './pages/ForgotPasswordPage.client.js';
*/ import ResetPasswordPage from './pages/ResetPasswordPage.client.js';
import ConfirmEmailPage from './pages/ConfirmEmailPage.client.js';
import LogoutPage from './pages/LogoutPage.client.js';
export { default as AuthPagesClient } from './components/AuthPages.js'; const PAGE_COMPONENTS = {
export { default as AuthPagesLayout } from './components/AuthPagesLayout.js'; login: LoginPage,
register: RegisterPage,
forgot: ForgotPasswordPage,
reset: ResetPasswordPage,
confirm: ConfirmEmailPage,
logout: LogoutPage,
};
export default function AuthPage({
params,
searchParams,
registerAction,
loginAction,
forgotPasswordAction,
resetPasswordAction,
verifyEmailAction,
logoutAction,
setSessionCookieAction,
redirectAfterLogin = '/',
currentUser = null,
}) {
const router = useRouter();
const [currentPage, setCurrentPage] = useState(null);
const [email, setEmail] = useState('');
const [token, setToken] = useState('');
useEffect(() => {
const fromParams = params?.auth?.[0];
if (fromParams) { setCurrentPage(fromParams); return; }
if (typeof window !== 'undefined') {
const match = window.location.pathname.match(/\/auth\/([^\/?]+)/);
setCurrentPage(match ? match[1] : 'login');
} else {
setCurrentPage('login');
}
}, [params]);
useEffect(() => {
const run = async () => {
// searchParams became a Promise in Next.js 15 — resolve both forms.
const resolved = searchParams && typeof searchParams.then === 'function'
? await searchParams
: searchParams;
const urlParams = typeof window !== 'undefined'
? new URLSearchParams(window.location.search)
: null;
setEmail(resolved?.email || urlParams?.get('email') || '');
setToken(resolved?.token || urlParams?.get('token') || '');
};
run();
}, [searchParams]);
const navigate = (page) => router.push(`/auth/${page}`);
if (!currentPage) return null;
const Page = PAGE_COMPONENTS[currentPage] || LoginPage;
const common = { onNavigate: navigate, currentUser };
switch (Page) {
case LoginPage:
return <Page {...common} onSubmit={loginAction} onSetSessionCookie={setSessionCookieAction} redirectAfterLogin={redirectAfterLogin} />;
case RegisterPage:
return <Page {...common} onSubmit={registerAction} />;
case ForgotPasswordPage:
return <Page {...common} onSubmit={forgotPasswordAction} />;
case ResetPasswordPage:
return <Page {...common} onSubmit={resetPasswordAction} email={email} token={token} />;
case ConfirmEmailPage:
return <Page {...common} onSubmit={verifyEmailAction} email={email} token={token} />;
case LogoutPage:
return <Page onLogout={logoutAction} onSetSessionCookie={setSessionCookieAction} />;
default:
return null;
}
}
+4 -15
View File
@@ -1,15 +1,4 @@
/** import AuthPageClient from './AuthPage.client.js';
* Auth Page - Server Component Wrapper for Next.js App Router
*
* Default auth UI: login, register, forgot, reset, confirm, logout at /auth/[...auth].
* Re-export in your app: export { default } from '@zen/core/features/auth/page';
*
* For custom auth pages (all flows) that match your site style, use components from
* '@zen/core/features/auth/components' and actions from '@zen/core/features/auth/actions'.
* See README-custom-login.md in this package. Basic sites can keep using this default page.
*/
import { AuthPagesClient } from '@zen/core/features/auth/pages';
import { import {
registerAction, registerAction,
loginAction, loginAction,
@@ -18,8 +7,8 @@ import {
resetPasswordAction, resetPasswordAction,
verifyEmailAction, verifyEmailAction,
setSessionCookie, setSessionCookie,
getSession getSession,
} from '@zen/core/features/auth/actions'; } from './actions.js';
export default async function AuthPage({ params, searchParams }) { export default async function AuthPage({ params, searchParams }) {
const session = await getSession(); const session = await getSession();
@@ -27,7 +16,7 @@ export default async function AuthPage({ params, searchParams }) {
return ( return (
<div className="min-h-screen flex items-center justify-center p-4 md:p-8"> <div className="min-h-screen flex items-center justify-center p-4 md:p-8">
<div className="max-w-md w-full"> <div className="max-w-md w-full">
<AuthPagesClient <AuthPageClient
params={params} params={params}
searchParams={searchParams} searchParams={searchParams}
registerAction={registerAction} registerAction={registerAction}
+358 -16
View File
@@ -1,19 +1,361 @@
/**
* Server Actions Export
* This file ONLY exports server actions - no client components
*/
'use server'; 'use server';
export { import { register, login, requestPasswordReset, resetPassword, verifyUserEmail } from './auth.js';
registerAction, import { validateSession, deleteSession } from './session.js';
loginAction, import { verifyEmailToken, verifyResetToken, sendVerificationEmail, sendPasswordResetEmail } from './email.js';
logoutAction, import { fail } from '@zen/core/shared/logger';
getSession, import { cookies, headers } from 'next/headers';
forgotPasswordAction, import { getSessionCookieName, getPublicBaseUrl } from '@zen/core/shared/config';
resetPasswordAction, import { checkRateLimit, getIpFromHeaders, formatRetryAfter } from '@zen/core/shared/rate-limit';
verifyEmailAction,
setSessionCookie,
refreshSessionCookie
} from './actions/authActions.js';
/**
* Errors that are safe to surface verbatim to the client (e.g. "token expired").
* All other errors — including library-layer and database errors — must be caught,
* logged server-side only, and replaced with a generic message to prevent internal
* detail disclosure.
*/
class UserFacingError extends Error {
constructor(message) {
super(message);
this.name = 'UserFacingError';
}
}
async function getClientIp() {
const h = await headers();
return getIpFromHeaders(h);
}
// Emitted at most once per process lifetime to avoid log flooding while still
// alerting operators that per-IP rate limiting is inactive for server actions.
let _rateLimitUnavailableWarned = false;
/**
* Apply per-IP rate limiting only when a real IP address is available.
*
* When IP resolves to 'unknown' (no trusted proxy configured), every caller
* shares the single bucket keyed '<action>:unknown'. A single attacker can
* exhaust that bucket in 5 requests and impose a 30-minute denial-of-service
* on every legitimate user. Rate limiting is therefore suspended for the
* 'unknown' case and a one-time operator warning is emitted instead,
* mirroring the identical policy applied to API routes in router.js.
*/
function enforceRateLimit(ip, action) {
if (ip === 'unknown') {
if (!_rateLimitUnavailableWarned) {
_rateLimitUnavailableWarned = true;
fail(
'Rate limiting inactive (server actions): client IP cannot be determined. ' +
'Set ZEN_TRUST_PROXY=true behind a verified reverse proxy to enable per-IP rate limiting.'
);
}
return null;
}
return checkRateLimit(ip, action);
}
/**
* Validate anti-bot fields submitted with forms.
* - _hp : honeypot field — must be empty
* - _t : form load timestamp (ms) — submission must be at least 1.5 s after page
* load AND no more than MAX_FORM_AGE_MS in the past. Both a lower bound
* (prevents instant automated submission) and an upper bound (prevents the
* trivial bypass of supplying an arbitrary past timestamp such as _t=1) are
* enforced. Future timestamps are also rejected.
*/
function validateAntiBotFields(formData) {
const honeypot = formData.get('_hp');
if (honeypot && honeypot.length > 0) {
return { valid: false, error: 'Requête invalide' };
}
const MIN_ELAPSED_MS = 1_500;
const MAX_FORM_AGE_MS = 10 * 60 * 1_000;
const now = Date.now();
const t = parseInt(formData.get('_t') || '0', 10);
const elapsed = now - t;
if (t === 0 || t > now || elapsed < MIN_ELAPSED_MS || elapsed > MAX_FORM_AGE_MS) {
return { valid: false, error: 'Requête invalide' };
}
return { valid: true };
}
export const COOKIE_NAME = getSessionCookieName();
export async function registerAction(formData) {
try {
const botCheck = validateAntiBotFields(formData);
if (!botCheck.valid) return { success: false, error: botCheck.error };
const ip = await getClientIp();
const rl = enforceRateLimit(ip, 'register');
if (rl && !rl.allowed) {
return { success: false, error: `Trop de tentatives. Réessayez dans ${formatRetryAfter(rl.retryAfterMs)}.` };
}
const email = formData.get('email');
const password = formData.get('password');
const name = formData.get('name');
const result = await register({ email, password, name });
await sendVerificationEmail(result.user.email, result.verificationToken, getPublicBaseUrl());
return {
success: true,
message: 'Compte créé avec succès. Consultez votre e-mail pour vérifier votre compte.',
user: result.user
};
} catch (error) {
// Never return raw error.message to the client — library and database errors
// (e.g. unique-constraint violations) expose internal table names and schema.
fail(`Auth: registerAction error: ${error.message}`);
return { success: false, error: 'Une erreur interne est survenue. Veuillez réessayer.' };
}
}
export async function loginAction(formData) {
try {
const botCheck = validateAntiBotFields(formData);
if (!botCheck.valid) return { success: false, error: botCheck.error };
const ip = await getClientIp();
const rl = enforceRateLimit(ip, 'login');
if (rl && !rl.allowed) {
return { success: false, error: `Trop de tentatives. Réessayez dans ${formatRetryAfter(rl.retryAfterMs)}.` };
}
const email = formData.get('email');
const password = formData.get('password');
const result = await login({ email, password });
// An HttpOnly cookie is the only safe transport for session tokens; setting it
// here keeps the token out of any JavaScript-readable response payload.
const cookieStore = await cookies();
cookieStore.set(COOKIE_NAME, result.session.token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 30,
path: '/'
});
return {
success: true,
message: 'Connexion réussie',
user: result.user
};
} catch (error) {
fail(`Auth: loginAction error: ${error.message}`);
return { success: false, error: 'Identifiants invalides ou erreur interne. Veuillez réessayer.' };
}
}
/**
* Set session cookie after verifying the token is a genuine live session.
*
* Client-callable. Without server-side token validation an attacker could
* supply any arbitrary string (including a stolen token for another user)
* and have it written as the HttpOnly session cookie, bypassing the protection
* HttpOnly is intended to provide. The token is therefore validated against
* the session store before the cookie is written.
*/
export async function setSessionCookie(token) {
try {
if (!token || typeof token !== 'string' || token.trim() === '') {
return { success: false, error: 'Jeton de session invalide' };
}
const session = await validateSession(token);
if (!session) {
return { success: false, error: 'Session invalide ou expirée' };
}
const cookieStore = await cookies();
cookieStore.set(COOKIE_NAME, token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 30,
path: '/'
});
return { success: true };
} catch (error) {
fail(`Auth: setSessionCookie error: ${error.message}`);
return { success: false, error: 'Une erreur interne est survenue' };
}
}
/**
* Re-validates the token before extending its cookie lifetime so that expired
* or revoked tokens cannot have their cookie window reopened by replay.
*/
export async function refreshSessionCookie(token) {
try {
if (!token || typeof token !== 'string' || token.trim() === '') {
return { success: false, error: 'Jeton de session invalide' };
}
const session = await validateSession(token);
if (!session) {
return { success: false, error: 'Session invalide ou expirée' };
}
const cookieStore = await cookies();
cookieStore.set(COOKIE_NAME, token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 30,
path: '/'
});
return { success: true };
} catch (error) {
fail(`Auth: refreshSessionCookie error: ${error.message}`);
return { success: false, error: 'Une erreur interne est survenue' };
}
}
export async function logoutAction() {
try {
const cookieStore = await cookies();
const token = cookieStore.get(COOKIE_NAME)?.value;
if (token) {
await deleteSession(token);
}
cookieStore.delete(COOKIE_NAME);
return { success: true, message: 'Déconnexion réussie' };
} catch (error) {
fail(`Auth: logoutAction error: ${error.message}`);
return { success: false, error: 'Une erreur interne est survenue. Veuillez réessayer.' };
}
}
export async function getSession() {
try {
const cookieStore = await cookies();
const token = cookieStore.get(COOKIE_NAME)?.value;
if (!token) return null;
const result = await validateSession(token);
if (result && result.sessionRefreshed) {
await refreshSessionCookie(token);
}
return result;
} catch (error) {
fail(`Auth: session validation error: ${error.message}`);
return null;
}
}
export async function forgotPasswordAction(formData) {
try {
const botCheck = validateAntiBotFields(formData);
if (!botCheck.valid) return { success: false, error: botCheck.error };
const ip = await getClientIp();
const rl = enforceRateLimit(ip, 'forgot_password');
if (rl && !rl.allowed) {
return { success: false, error: `Trop de tentatives. Réessayez dans ${formatRetryAfter(rl.retryAfterMs)}.` };
}
const email = formData.get('email');
const result = await requestPasswordReset(email);
if (result.token) {
await sendPasswordResetEmail(email, result.token, getPublicBaseUrl());
}
return {
success: true,
message: 'Si un compte existe avec cet e-mail, vous recevrez un lien pour réinitialiser votre mot de passe.'
};
} catch (error) {
fail(`Auth: forgotPasswordAction error: ${error.message}`);
return { success: false, error: 'Une erreur interne est survenue. Veuillez réessayer.' };
}
}
export async function resetPasswordAction(formData) {
try {
const ip = await getClientIp();
const rl = enforceRateLimit(ip, 'reset_password');
if (rl && !rl.allowed) {
return { success: false, error: `Trop de tentatives. Réessayez dans ${formatRetryAfter(rl.retryAfterMs)}.` };
}
const email = formData.get('email');
const token = formData.get('token');
const newPassword = formData.get('newPassword');
// Throw UserFacingError so the specific message reaches the client while
// unexpected system errors are sanitized in the catch below.
const isValid = await verifyResetToken(email, token);
if (!isValid) {
throw new UserFacingError('Jeton de réinitialisation invalide ou expiré');
}
await resetPassword({ email, token, newPassword });
return {
success: true,
message: 'Mot de passe réinitialisé avec succès. Vous pouvez maintenant vous connecter avec votre nouveau mot de passe.'
};
} catch (error) {
if (error instanceof UserFacingError) {
return { success: false, error: error.message };
}
fail(`Auth: resetPasswordAction error: ${error.message}`);
return { success: false, error: 'Une erreur interne est survenue. Veuillez réessayer.' };
}
}
export async function verifyEmailAction(formData) {
try {
const ip = await getClientIp();
const rl = enforceRateLimit(ip, 'verify_email');
if (rl && !rl.allowed) {
return { success: false, error: `Trop de tentatives. Réessayez dans ${formatRetryAfter(rl.retryAfterMs)}.` };
}
const email = formData.get('email');
const token = formData.get('token');
const isValid = await verifyEmailToken(email, token);
if (!isValid) {
throw new UserFacingError('Jeton de vérification invalide ou expiré');
}
const { findOne } = await import('../../core/database/crud.js');
const user = await findOne('zen_auth_users', { email });
if (!user) {
throw new UserFacingError('Utilisateur introuvable');
}
await verifyUserEmail(user.id);
return {
success: true,
message: 'E-mail vérifié avec succès. Vous pouvez maintenant vous connecter.'
};
} catch (error) {
if (error instanceof UserFacingError) {
return { success: false, error: error.message };
}
fail(`Auth: verifyEmailAction error: ${error.message}`);
return { success: false, error: 'Une erreur interne est survenue. Veuillez réessayer.' };
}
}
+1 -1
View File
@@ -8,7 +8,7 @@
*/ */
import { query, updateById } from '@zen/core/database'; import { query, updateById } from '@zen/core/database';
import { updateUser } from './lib/auth.js'; import { updateUser } from './auth.js';
import { listRoles, getRoleById, createRole, updateRole, deleteRole, getUserRoles, assignUserRole, revokeUserRole } from '@zen/core/users'; import { listRoles, getRoleById, createRole, updateRole, deleteRole, getUserRoles, assignUserRole, revokeUserRole } 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';
+4 -4
View File
@@ -5,16 +5,16 @@ import {
requestPasswordReset, requestPasswordReset,
verifyUserEmail, verifyUserEmail,
updateUser updateUser
} from '../../../core/users/auth.js'; } from '../../core/users/auth.js';
import { sendPasswordChangedEmail } from './email.js'; import { sendPasswordChangedEmail } from './email.js';
// Inject email sending into register (verification email) — kept here because // Inject sendPasswordChangedEmail — the JSX template lives in features/auth so
// it depends on JSX templates that live in features/auth. // the auth feature stays self-contained and core/users can remain pure server
// logic without JSX.
export function register(userData) { export function register(userData) {
return _register(userData); return _register(userData);
} }
// Inject sendPasswordChangedEmail — the template lives in features/auth.
export function resetPassword(resetData) { export function resetPassword(resetData) {
return _resetPassword(resetData, { onPasswordChanged: sendPasswordChangedEmail }); return _resetPassword(resetData, { onPasswordChanged: sendPasswordChangedEmail });
} }
+1 -38
View File
@@ -1,41 +1,4 @@
/** 'use client';
* Auth Components Export
*
* Use these components to build custom auth pages for every flow (login, register, forgot,
* reset, confirm, logout) so they match your site's style.
* For a ready-made catch-all auth UI, use AuthPagesClient from '@zen/core/features/auth/pages'.
* For the default full-page auth (no custom layout), re-export from '@zen/core/features/auth/page'.
*
* --- Custom auth pages (all types) ---
*
* Pattern: server component loads session/searchParams and passes actions to a client wrapper;
* client wrapper uses useRouter for onNavigate and renders the Zen component.
*
* Component props:
* - LoginPage: onSubmit (loginAction), onSetSessionCookie, onNavigate, redirectAfterLogin, currentUser
* - RegisterPage: onSubmit (registerAction), onNavigate, currentUser
* - ForgotPasswordPage: onSubmit (forgotPasswordAction), onNavigate, currentUser
* - ResetPasswordPage: onSubmit (resetPasswordAction), onNavigate, email, token (from URL)
* - ConfirmEmailPage: onSubmit (verifyEmailAction), onNavigate, email, token (from URL)
* - LogoutPage: onLogout (logoutAction), onSetSessionCookie (optional)
*
* onNavigate receives 'login' | 'register' | 'forgot' | 'reset'. Map to your routes (e.g. /auth/${page}).
* For reset/confirm, pass email and token from searchParams. Full guide: see README-custom-login.md in this package.
* Protect routes with protect() from '@zen/core/features/auth', redirectTo your login path.
*
* --- Dashboard / user display ---
*
* UserAvatar, UserMenu, AccountSection, useCurrentUser: see README-dashboard.md.
*/
export { default as AuthPagesLayout } from './AuthPagesLayout.js';
export { default as AuthPagesClient } from './AuthPages.js';
export { default as LoginPage } from './pages/LoginPage.js';
export { default as RegisterPage } from './pages/RegisterPage.js';
export { default as ForgotPasswordPage } from './pages/ForgotPasswordPage.js';
export { default as ResetPasswordPage } from './pages/ResetPasswordPage.js';
export { default as ConfirmEmailPage } from './pages/ConfirmEmailPage.js';
export { default as LogoutPage } from './pages/LogoutPage.js';
export { default as UserAvatar } from './UserAvatar.js'; export { default as UserAvatar } from './UserAvatar.js';
export { default as UserMenu } from './UserMenu.js'; export { default as UserMenu } from './UserMenu.js';
+4 -4
View File
@@ -1,12 +1,12 @@
import { render } from '@react-email/components'; import { render } from '@react-email/components';
import { fail, info } from '@zen/core/shared/logger'; import { fail, info } from '@zen/core/shared/logger';
import { sendEmail } from '@zen/core/email'; import { sendEmail } from '@zen/core/email';
import { VerificationEmail } from '../templates/VerificationEmail.jsx'; import { VerificationEmail } from './templates/VerificationEmail.jsx';
import { PasswordResetEmail } from '../templates/PasswordResetEmail.jsx'; import { PasswordResetEmail } from './templates/PasswordResetEmail.jsx';
import { PasswordChangedEmail } from '../templates/PasswordChangedEmail.jsx'; import { PasswordChangedEmail } from './templates/PasswordChangedEmail.jsx';
export { createEmailVerification, verifyEmailToken, createPasswordReset, verifyResetToken, deleteResetToken } export { createEmailVerification, verifyEmailToken, createPasswordReset, verifyResetToken, deleteResetToken }
from '../../../core/users/verifications.js'; from '../../core/users/verifications.js';
async function sendVerificationEmail(email, token, baseUrl) { async function sendVerificationEmail(email, token, baseUrl) {
const appName = process.env.ZEN_NAME || 'ZEN'; const appName = process.env.ZEN_NAME || 'ZEN';
+9 -21
View File
@@ -1,11 +1,9 @@
/** /**
* Zen Authentication Module - Server-side utilities * Zen Authentication — server barrel.
* * Client components live in @zen/core/features/auth/components.
* For client components, use '@zen/core/auth/pages' * Server actions live in @zen/core/features/auth/actions.
* For server actions, use '@zen/core/auth/actions'
*/ */
// Authentication library (server-side only)
export { export {
register, register,
login, login,
@@ -13,18 +11,16 @@ export {
resetPassword, resetPassword,
verifyUserEmail, verifyUserEmail,
updateUser updateUser
} from './lib/auth.js'; } from './auth.js';
// Session management (server-side only)
export { export {
createSession, createSession,
validateSession, validateSession,
deleteSession, deleteSession,
deleteUserSessions, deleteUserSessions,
refreshSession refreshSession
} from './lib/session.js'; } from './session.js';
// Email utilities (server-side only)
export { export {
createEmailVerification, createEmailVerification,
verifyEmailToken, verifyEmailToken,
@@ -34,24 +30,17 @@ export {
sendVerificationEmail, sendVerificationEmail,
sendPasswordResetEmail, sendPasswordResetEmail,
sendPasswordChangedEmail sendPasswordChangedEmail
} from './lib/email.js'; } from './email.js';
// Password utilities (server-side only)
export { export {
hashPassword, hashPassword,
verifyPassword, verifyPassword,
generateToken, generateToken,
generateId generateId
} from './lib/password.js'; } from './password.js';
// Middleware (server-side only) export { protect, checkAuth, requireRole } from './protect.js';
export {
protect,
checkAuth,
requireRole
} from './middleware/protect.js';
// Server Actions (server-side only)
export { export {
registerAction, registerAction,
loginAction, loginAction,
@@ -62,5 +51,4 @@ export {
verifyEmailAction, verifyEmailAction,
setSessionCookie, setSessionCookie,
refreshSessionCookie refreshSessionCookie
} from './actions/authActions.js'; } from './actions.js';
+1 -1
View File
@@ -1 +1 @@
export { hashPassword, verifyPassword, generateToken, generateId } from '../../../core/users/password.js'; export { hashPassword, verifyPassword, generateToken, generateId } from '../../core/users/password.js';
+8 -72
View File
@@ -1,83 +1,19 @@
/** import { getSession } from './actions.js';
* Route Protection Middleware
* Utilities to protect routes and check authentication
*/
import { getSession } from '../actions/authActions.js';
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
/** export async function protect({ redirectTo = '/auth/login' } = {}) {
* Protect a page - requires authentication
* Use this in server components to require authentication
*
* @param {Object} options - Protection options
* @param {string} options.redirectTo - Where to redirect if not authenticated (default: '/auth/login')
* @returns {Promise<Object>} Session object with user data
*
* @example
* // In a server component:
* import { protect } from '@zen/core/features/auth';
*
* export default async function ProtectedPage() {
* const session = await protect();
* return <div>Welcome, {session.user.name}!</div>;
* }
*/
async function protect(options = {}) {
const { redirectTo = '/auth/login' } = options;
const session = await getSession(); const session = await getSession();
if (!session) redirect(redirectTo);
if (!session) {
redirect(redirectTo);
}
return session; return session;
} }
/** export async function checkAuth() {
* Check if user is authenticated return getSession();
* Use this when you want to check authentication without forcing a redirect
*
* @returns {Promise<Object|null>} Session object or null if not authenticated
*
* @example
* import { checkAuth } from '@zen/core/features/auth';
*
* export default async function Page() {
* const session = await checkAuth();
* return session ? <div>Logged in</div> : <div>Not logged in</div>;
* }
*/
async function checkAuth() {
return await getSession();
} }
/** export async function requireRole(allowedRoles = [], { redirectTo = '/auth/login', forbiddenRedirect = '/' } = {}) {
* Require a specific role
* @param {Array<string>} allowedRoles - Array of allowed roles
* @param {Object} options - Options
* @returns {Promise<Object>} Session object
*/
async function requireRole(allowedRoles = [], options = {}) {
const { redirectTo = '/auth/login', forbiddenRedirect = '/' } = options;
const session = await getSession(); const session = await getSession();
if (!session) redirect(redirectTo);
if (!session) { if (!allowedRoles.includes(session.user.role)) redirect(forbiddenRedirect);
redirect(redirectTo);
}
if (!allowedRoles.includes(session.user.role)) {
redirect(forbiddenRedirect);
}
return session; return session;
} }
export {
protect,
checkAuth,
requireRole
};
+1 -1
View File
@@ -1 +1 @@
export { createSession, validateSession, deleteSession, deleteUserSessions, refreshSession } from '../../../core/users/session.js'; export { createSession, validateSession, deleteSession, deleteUserSessions, refreshSession } from '../../core/users/session.js';
+24 -34
View File
@@ -1,41 +1,37 @@
/** /**
* Core Feature Database Initialization (CLI) * Core Feature Database Initialization (CLI)
* *
* Initializes and drops DB tables for each core feature. * Initialise et supprime les tables des features core. La liste est aujourd'hui
* Features are discovered from CORE_FEATURES — no manual wiring needed * limitée à auth — le seul feature avec un schéma. Ajouter ici si un autre
* when adding a new feature. * feature gagne un db.js avec createTables()/dropTables().
*/ */
import { CORE_FEATURES } from './features.registry.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';
/** const FEATURES = [
* Initialize all core feature databases. { name: 'auth', createTables: authCreate, dropTables: authDrop },
* @returns {Promise<{ created: string[], skipped: string[] }>} ];
*/
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 featureName of CORE_FEATURES) { for (const { name, createTables } of FEATURES) {
try { try {
step(`Initializing ${featureName}...`); step(`Initializing ${name}...`);
const db = await import(`./${featureName}/db.js`); if (typeof createTables !== 'function') {
info(`${name} has no createTables function`);
if (typeof db.createTables === 'function') { continue;
const result = await db.createTables();
if (result?.created) created.push(...result.created);
if (result?.skipped) skipped.push(...result.skipped);
done(`${featureName} initialized`);
} else {
info(`${featureName} has no createTables function`);
} }
const result = await createTables();
if (result?.created) created.push(...result.created);
if (result?.skipped) skipped.push(...result.skipped);
done(`${name} initialized`);
} catch (error) { } catch (error) {
fail(`${featureName}: ${error.message}`); fail(`${name}: ${error.message}`);
throw error; throw error;
} }
} }
@@ -43,22 +39,16 @@ export async function initFeatures() {
return { created, skipped }; return { created, skipped };
} }
/**
* Drop all core feature databases in reverse order.
* @returns {Promise<void>}
*/
export async function dropFeatures() { export async function dropFeatures() {
for (const featureName of [...CORE_FEATURES].reverse()) { for (const { name, dropTables } of [...FEATURES].reverse()) {
try { try {
const db = await import(`./${featureName}/db.js`); if (typeof dropTables !== 'function') {
info(`${name} has no dropTables function`);
if (typeof db.dropTables === 'function') { continue;
await db.dropTables();
} else {
info(`${featureName} has no dropTables function`);
} }
await dropTables();
} catch (error) { } catch (error) {
fail(`${featureName}: ${error.message}`); fail(`${name}: ${error.message}`);
throw error; throw error;
} }
} }
+1 -1
View File
@@ -1,7 +1,7 @@
'use client'; 'use client';
import React from 'react'; import React from 'react';
import { Recycle03Icon } from '../Icons'; import { Recycle03Icon } from '../icons/index.js';
const Loading = ({ size = 'md' }) => { const Loading = ({ size = 'md' }) => {
const sizes = { const sizes = {
+1 -1
View File
@@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import { Dialog } from '@headlessui/react'; import { Dialog } from '@headlessui/react';
import { Cancel01Icon } from '../Icons'; import { Cancel01Icon } from '../icons/index.js';
import Button from './Button'; import Button from './Button';
const Modal = ({ const Modal = ({
+1 -1
View File
@@ -2,7 +2,7 @@
import React from 'react'; import React from 'react';
import Badge from './Badge'; import Badge from './Badge';
import { TorriGateIcon } from '../Icons'; import { TorriGateIcon } from '../icons/index.js';
const ROW_SIZE = { const ROW_SIZE = {
sm: { cell: 'px-4 py-[11px]', header: 'px-4 py-[9px]', mobile: 'p-4' }, sm: { cell: 'px-4 py-[11px]', header: 'px-4 py-[9px]', mobile: 'p-4' },
+1 -1
View File
@@ -16,7 +16,7 @@
import { configureRouter, registerFeatureRoutes, clearRouterConfig, clearFeatureRoutes } from '@zen/core/api'; import { configureRouter, registerFeatureRoutes, clearRouterConfig, clearFeatureRoutes } from '@zen/core/api';
import { registerStoragePolicies, clearStorageConfig } from '@zen/core/storage'; import { registerStoragePolicies, clearStorageConfig } from '@zen/core/storage';
import { validateSession } from '../../features/auth/lib/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 { done, warn } from './logger.js';
+62 -78
View File
@@ -1,98 +1,82 @@
import { defineConfig } from 'tsup'; import { defineConfig } from 'tsup';
import { readFileSync, readdirSync, statSync } from 'node:fs';
import { join } from 'node:path';
const pkg = JSON.parse(readFileSync('./package.json', 'utf8'));
// Source de vérité #1 : package.json#exports. Donne la liste des points
// d'entrée publics et la liste des self-imports à marquer external.
const exportEntries = Object.values(pkg.exports)
.map(e => e.import).filter(Boolean)
.map(p => p.replace('./dist/', 'src/'));
const selfImports = Object.keys(pkg.exports)
.filter(k => k !== '.' && !k.endsWith('.css'))
.map(k => '@zen/core' + k.slice(1));
// Source de vérité #2 : les fichiers *.server.js et *.client.js sous src/.
// Convention : un tel fichier est *toujours* un point d'entrée non-bundlé —
// soit il fait partie de l'API publique (listé dans exports), soit c'est un
// wiring interne (pages, widgets) qui doit rester un module séparé pour
// préserver les frontières RSC / 'use client'.
function walk(dir, out = []) {
for (const name of readdirSync(dir)) {
const full = join(dir, name);
if (statSync(full).isDirectory()) walk(full, out);
else if (/\.(server|client)\.js$/.test(name)) out.push(full);
}
return out;
}
const boundaryFiles = walk('src');
// Dédup : un chemin déclaré dans exports ET détecté par la glob ne devient
// pas deux entrées.
const allEntries = [...new Set([...exportEntries, ...boundaryFiles])];
const SHARED_EXTERNALS = [
'react', 'react-dom', 'next',
'pg', 'dotenv', 'dotenv/config', 'resend', 'node-cron',
'@react-email/components', '@aws-sdk/client-s3', '@aws-sdk/s3-request-presigner',
'readline', 'crypto', 'url', 'fs', 'path', 'net', 'dns', 'tls',
...selfImports,
];
const unbundled = allEntries.filter(e => /\.(server|client)\.js$/.test(e));
const bundled = allEntries.filter(e => !/\.(server|client)\.js$/.test(e));
const esbuildBase = (o) => {
o.loader = { '.js': 'jsx', '.jsx': 'jsx' };
o.jsx = 'automatic';
};
export default defineConfig([ export default defineConfig([
// Main bundled files
{ {
entry: [ entry: bundled,
'src/index.js',
'src/features/auth/index.js',
'src/features/auth/actions.js',
'src/features/auth/pages.js',
'src/features/auth/components/index.js',
'src/features/admin/index.js',
'src/features/admin/actions.js',
'src/features/admin/pages.js',
'src/features/admin/components/index.js',
'src/core/users/index.js',
'src/core/users/constants.js',
'src/core/api/index.js',
'src/core/api/route-handler.js',
'src/core/cron/index.js',
'src/core/database/index.js',
'src/core/database/cli.js',
'src/core/email/index.js',
'src/core/email/templates/index.js',
'src/core/storage/index.js',
'src/core/toast/index.js',
'src/core/themes/index.js',
'src/features/provider/index.js',
'src/shared/components/index.js',
'src/shared/Icons.js',
'src/shared/lib/metadata/index.js',
'src/shared/lib/logger.js',
'src/shared/lib/appConfig.js',
'src/shared/lib/rateLimit.js',
],
format: ['esm'], format: ['esm'],
dts: false, dts: false,
splitting: false, splitting: false,
sourcemap: false, sourcemap: false,
clean: true, clean: true,
external: ['react', 'react-dom', 'next', 'pg', 'dotenv', 'dotenv/config', 'resend', '@react-email/components', 'node-cron', 'readline', 'crypto', 'url', 'fs', 'path', 'net', 'dns', 'tls', '@zen/core/users', '@zen/core/users/constants', '@zen/core/api', '@zen/core/cron', '@zen/core/database', '@zen/core/email', '@zen/core/email/templates', '@zen/core/storage', '@zen/core/toast', '@zen/core/features/auth', '@zen/core/features/auth/actions', '@zen/core/features/auth/components', '@zen/core/shared/components', '@zen/core/shared/icons', '@zen/core/shared/logger', '@zen/core/shared/config', '@zen/core/shared/rate-limit', '@zen/core/themes', '@aws-sdk/client-s3', '@aws-sdk/s3-request-presigner'],
noExternal: [],
bundle: true, bundle: true,
banner: { external: SHARED_EXTERNALS,
js: ``, esbuildOptions(o) {
}, esbuildBase(o);
esbuildOptions(options) { o.platform = 'neutral';
options.loader = { o.legalComments = 'inline';
'.js': 'jsx',
'.jsx': 'jsx',
};
options.jsx = 'automatic';
options.platform = 'neutral';
options.legalComments = 'inline';
}, },
}, },
// Page wrappers and server-only files - NOT bundled to preserve boundaries and share instances
{ {
entry: [ entry: unbundled,
'src/features/auth/page.js',
'src/features/admin/page.js',
'src/features/admin/navigation.server.js',
'src/features/admin/dashboard/registry.js',
'src/features/admin/dashboard/serverRegistry.js',
'src/features/admin/dashboard/widgets/index.server.js',
'src/features/admin/dashboard/widgets/users.server.js',
'src/features/dashboard.server.js',
],
format: ['esm'], format: ['esm'],
dts: false, dts: false,
splitting: false, splitting: false,
sourcemap: false, sourcemap: false,
clean: false, // Don't clean, we already did in first config clean: false,
external: [ bundle: false,
'react', external: SHARED_EXTERNALS,
'react-dom', esbuildOptions(o) {
'next', esbuildBase(o);
'@zen/core', o.outbase = 'src';
'@zen/core/features/auth/pages',
'@zen/core/features/auth/actions',
'@zen/core/features/admin',
'@zen/core/features/admin/pages',
'@zen/core/features/admin/actions',
'@zen/core/features/admin/navigation',
'@zen/core/toast',
],
bundle: false, // Don't bundle these files
esbuildOptions(options) {
options.outbase = 'src';
options.loader = {
'.js': 'jsx',
'.jsx': 'jsx',
};
options.jsx = 'automatic';
}, },
}, },
]); ]);