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
### 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
// tsup.config.js — external (premier bloc)
'@zen/core/api', // ← à ajouter si src/core/api/index.js est un entry tsup
'@zen/core/database',
'@zen/core/storage',
// etc.
// app/zen.extensions.js — projet consommateur
import {
registerWidget,
registerWidgetFetcher,
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 :**
- 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`)
- Registre d'extensions runtime pour widgets, nav items et pages (`registerWidget`, `registerNavItem`, `registerPage`)
- Gestion des utilisateurs depuis l'interface
**Navigation :**
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@zen/core",
"version": "1.3.47",
"version": "1.4.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@zen/core",
"version": "1.3.47",
"version": "1.4.1",
"license": "GPL-3.0-only",
"dependencies": {
"@headlessui/react": "^2.0.0",
+33 -100
View File
@@ -1,6 +1,6 @@
{
"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.",
"repository": {
"type": "git",
@@ -48,104 +48,37 @@
"react": "^19.0.0"
},
"exports": {
".": {
"import": "./dist/index.js"
},
"./features/auth": {
"import": "./dist/features/auth/index.js"
},
"./features/auth/actions": {
"import": "./dist/features/auth/actions.js"
},
"./features/auth/pages": {
"import": "./dist/features/auth/pages.js"
},
"./features/auth/page": {
"import": "./dist/features/auth/page.js"
},
"./features/auth/components": {
"import": "./dist/features/auth/components/index.js"
},
"./features/admin": {
"import": "./dist/features/admin/index.js"
},
"./features/admin/actions": {
"import": "./dist/features/admin/actions.js"
},
"./features/admin/navigation": {
"import": "./dist/features/admin/navigation.server.js"
},
"./features/admin/pages": {
"import": "./dist/features/admin/pages.js"
},
"./features/admin/page": {
"import": "./dist/features/admin/page.js"
},
"./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"
}
".": { "import": "./dist/index.js" },
"./features/auth": { "import": "./dist/features/auth/index.js" },
"./features/auth/actions": { "import": "./dist/features/auth/actions.js" },
"./features/auth/server": { "import": "./dist/features/auth/AuthPage.server.js" },
"./features/auth/client": { "import": "./dist/features/auth/AuthPage.client.js" },
"./features/auth/components": { "import": "./dist/features/auth/components/index.js" },
"./features/admin": { "import": "./dist/features/admin/index.js" },
"./features/admin/server": { "import": "./dist/features/admin/AdminPage.server.js" },
"./features/admin/client": { "import": "./dist/features/admin/AdminPage.client.js" },
"./features/admin/components": { "import": "./dist/features/admin/components/index.js" },
"./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" },
"./api/handler": { "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/index.js" },
"./shared/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';
/**
* 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);
});
+85 -8
View File
@@ -1,12 +1,89 @@
'use client';
/**
* Auth Pages Export for Next.js App Router
*
* This exports the auth client components.
* Users must create their own server component wrapper that imports the actions.
*/
import { useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';
import LoginPage from './pages/LoginPage.client.js';
import RegisterPage from './pages/RegisterPage.client.js';
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';
export { default as AuthPagesLayout } from './components/AuthPagesLayout.js';
const PAGE_COMPONENTS = {
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 @@
/**
* 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 AuthPageClient from './AuthPage.client.js';
import {
registerAction,
loginAction,
@@ -18,8 +7,8 @@ import {
resetPasswordAction,
verifyEmailAction,
setSessionCookie,
getSession
} from '@zen/core/features/auth/actions';
getSession,
} from './actions.js';
export default async function AuthPage({ params, searchParams }) {
const session = await getSession();
@@ -27,7 +16,7 @@ export default async function AuthPage({ params, searchParams }) {
return (
<div className="min-h-screen flex items-center justify-center p-4 md:p-8">
<div className="max-w-md w-full">
<AuthPagesClient
<AuthPageClient
params={params}
searchParams={searchParams}
registerAction={registerAction}
+358 -16
View File
@@ -1,19 +1,361 @@
/**
* Server Actions Export
* This file ONLY exports server actions - no client components
*/
'use server';
export {
registerAction,
loginAction,
logoutAction,
getSession,
forgotPasswordAction,
resetPasswordAction,
verifyEmailAction,
setSessionCookie,
refreshSessionCookie
} from './actions/authActions.js';
import { register, login, requestPasswordReset, resetPassword, verifyUserEmail } from './auth.js';
import { validateSession, deleteSession } from './session.js';
import { verifyEmailToken, verifyResetToken, sendVerificationEmail, sendPasswordResetEmail } from './email.js';
import { fail } from '@zen/core/shared/logger';
import { cookies, headers } from 'next/headers';
import { getSessionCookieName, getPublicBaseUrl } from '@zen/core/shared/config';
import { checkRateLimit, getIpFromHeaders, formatRetryAfter } from '@zen/core/shared/rate-limit';
/**
* 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 { updateUser } from './lib/auth.js';
import { updateUser } from './auth.js';
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';
+4 -4
View File
@@ -5,16 +5,16 @@ import {
requestPasswordReset,
verifyUserEmail,
updateUser
} from '../../../core/users/auth.js';
} from '../../core/users/auth.js';
import { sendPasswordChangedEmail } from './email.js';
// Inject email sending into register (verification email) — kept here because
// it depends on JSX templates that live in features/auth.
// Inject sendPasswordChangedEmail — the JSX template lives in features/auth so
// the auth feature stays self-contained and core/users can remain pure server
// logic without JSX.
export function register(userData) {
return _register(userData);
}
// Inject sendPasswordChangedEmail — the template lives in features/auth.
export function resetPassword(resetData) {
return _resetPassword(resetData, { onPasswordChanged: sendPasswordChangedEmail });
}
+1 -38
View File
@@ -1,41 +1,4 @@
/**
* 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';
'use client';
export { default as UserAvatar } from './UserAvatar.js';
export { default as UserMenu } from './UserMenu.js';
+4 -4
View File
@@ -1,12 +1,12 @@
import { render } from '@react-email/components';
import { fail, info } from '@zen/core/shared/logger';
import { sendEmail } from '@zen/core/email';
import { VerificationEmail } from '../templates/VerificationEmail.jsx';
import { PasswordResetEmail } from '../templates/PasswordResetEmail.jsx';
import { PasswordChangedEmail } from '../templates/PasswordChangedEmail.jsx';
import { VerificationEmail } from './templates/VerificationEmail.jsx';
import { PasswordResetEmail } from './templates/PasswordResetEmail.jsx';
import { PasswordChangedEmail } from './templates/PasswordChangedEmail.jsx';
export { createEmailVerification, verifyEmailToken, createPasswordReset, verifyResetToken, deleteResetToken }
from '../../../core/users/verifications.js';
from '../../core/users/verifications.js';
async function sendVerificationEmail(email, token, baseUrl) {
const appName = process.env.ZEN_NAME || 'ZEN';
+9 -21
View File
@@ -1,11 +1,9 @@
/**
* Zen Authentication Module - Server-side utilities
*
* For client components, use '@zen/core/auth/pages'
* For server actions, use '@zen/core/auth/actions'
* Zen Authentication — server barrel.
* Client components live in @zen/core/features/auth/components.
* Server actions live in @zen/core/features/auth/actions.
*/
// Authentication library (server-side only)
export {
register,
login,
@@ -13,18 +11,16 @@ export {
resetPassword,
verifyUserEmail,
updateUser
} from './lib/auth.js';
} from './auth.js';
// Session management (server-side only)
export {
createSession,
validateSession,
deleteSession,
deleteUserSessions,
refreshSession
} from './lib/session.js';
} from './session.js';
// Email utilities (server-side only)
export {
createEmailVerification,
verifyEmailToken,
@@ -34,24 +30,17 @@ export {
sendVerificationEmail,
sendPasswordResetEmail,
sendPasswordChangedEmail
} from './lib/email.js';
} from './email.js';
// Password utilities (server-side only)
export {
hashPassword,
verifyPassword,
generateToken,
generateId
} from './lib/password.js';
} from './password.js';
// Middleware (server-side only)
export {
protect,
checkAuth,
requireRole
} from './middleware/protect.js';
export { protect, checkAuth, requireRole } from './protect.js';
// Server Actions (server-side only)
export {
registerAction,
loginAction,
@@ -62,5 +51,4 @@ export {
verifyEmailAction,
setSessionCookie,
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 @@
/**
* Route Protection Middleware
* Utilities to protect routes and check authentication
*/
import { getSession } from '../actions/authActions.js';
import { getSession } from './actions.js';
import { redirect } from 'next/navigation';
/**
* 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;
export async function protect({ redirectTo = '/auth/login' } = {}) {
const session = await getSession();
if (!session) {
redirect(redirectTo);
}
if (!session) redirect(redirectTo);
return session;
}
/**
* Check if user is authenticated
* 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 checkAuth() {
return getSession();
}
/**
* 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;
export async function requireRole(allowedRoles = [], { redirectTo = '/auth/login', forbiddenRedirect = '/' } = {}) {
const session = await getSession();
if (!session) {
redirect(redirectTo);
}
if (!allowedRoles.includes(session.user.role)) {
redirect(forbiddenRedirect);
}
if (!session) redirect(redirectTo);
if (!allowedRoles.includes(session.user.role)) redirect(forbiddenRedirect);
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)
*
* Initializes and drops DB tables for each core feature.
* Features are discovered from CORE_FEATURES — no manual wiring needed
* when adding a new feature.
* Initialise et supprime les tables des features core. La liste est aujourd'hui
* limitée à auth — le seul feature avec un schéma. Ajouter ici si un autre
* feature gagne un db.js avec createTables()/dropTables().
*/
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';
/**
* Initialize all core feature databases.
* @returns {Promise<{ created: string[], skipped: string[] }>}
*/
const FEATURES = [
{ name: 'auth', createTables: authCreate, dropTables: authDrop },
];
export async function initFeatures() {
const created = [];
const skipped = [];
step('Initializing feature databases...');
for (const featureName of CORE_FEATURES) {
for (const { name, createTables } of FEATURES) {
try {
step(`Initializing ${featureName}...`);
const db = await import(`./${featureName}/db.js`);
if (typeof db.createTables === 'function') {
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`);
step(`Initializing ${name}...`);
if (typeof createTables !== 'function') {
info(`${name} has no createTables function`);
continue;
}
const result = await createTables();
if (result?.created) created.push(...result.created);
if (result?.skipped) skipped.push(...result.skipped);
done(`${name} initialized`);
} catch (error) {
fail(`${featureName}: ${error.message}`);
fail(`${name}: ${error.message}`);
throw error;
}
}
@@ -43,22 +39,16 @@ export async function initFeatures() {
return { created, skipped };
}
/**
* Drop all core feature databases in reverse order.
* @returns {Promise<void>}
*/
export async function dropFeatures() {
for (const featureName of [...CORE_FEATURES].reverse()) {
for (const { name, dropTables } of [...FEATURES].reverse()) {
try {
const db = await import(`./${featureName}/db.js`);
if (typeof db.dropTables === 'function') {
await db.dropTables();
} else {
info(`${featureName} has no dropTables function`);
if (typeof dropTables !== 'function') {
info(`${name} has no dropTables function`);
continue;
}
await dropTables();
} catch (error) {
fail(`${featureName}: ${error.message}`);
fail(`${name}: ${error.message}`);
throw error;
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
'use client';
import React from 'react';
import { Recycle03Icon } from '../Icons';
import { Recycle03Icon } from '../icons/index.js';
const Loading = ({ size = 'md' }) => {
const sizes = {
+1 -1
View File
@@ -1,6 +1,6 @@
import React from 'react';
import { Dialog } from '@headlessui/react';
import { Cancel01Icon } from '../Icons';
import { Cancel01Icon } from '../icons/index.js';
import Button from './Button';
const Modal = ({
+1 -1
View File
@@ -2,7 +2,7 @@
import React from 'react';
import Badge from './Badge';
import { TorriGateIcon } from '../Icons';
import { TorriGateIcon } from '../icons/index.js';
const ROW_SIZE = {
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 { 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 { storageAccessPolicies } from '../../features/auth/storage-policies.js';
import { done, warn } from './logger.js';
+62 -78
View File
@@ -1,98 +1,82 @@
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([
// Main bundled files
{
entry: [
'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',
],
entry: bundled,
format: ['esm'],
dts: false,
splitting: false,
sourcemap: false,
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,
banner: {
js: ``,
},
esbuildOptions(options) {
options.loader = {
'.js': 'jsx',
'.jsx': 'jsx',
};
options.jsx = 'automatic';
options.platform = 'neutral';
options.legalComments = 'inline';
external: SHARED_EXTERNALS,
esbuildOptions(o) {
esbuildBase(o);
o.platform = 'neutral';
o.legalComments = 'inline';
},
},
// Page wrappers and server-only files - NOT bundled to preserve boundaries and share instances
{
entry: [
'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',
],
entry: unbundled,
format: ['esm'],
dts: false,
splitting: false,
sourcemap: false,
clean: false, // Don't clean, we already did in first config
external: [
'react',
'react-dom',
'next',
'@zen/core',
'@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';
clean: false,
bundle: false,
external: SHARED_EXTERNALS,
esbuildOptions(o) {
esbuildBase(o);
o.outbase = 'src';
},
},
]);