From 0106bc4ea04cb869b9ba873b936fb932467fc8a7 Mon Sep 17 00:00:00 2001 From: Hyko Date: Wed, 22 Apr 2026 14:13:30 -0400 Subject: [PATCH] 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 --- docs/DEV.md | 69 +++- docs/PROJECT.md | 3 +- package-lock.json | 4 +- package.json | 133 ++----- src/features/admin/AdminPage.client.js | 42 +- src/features/admin/AdminPage.server.js | 28 +- src/features/admin/components/AdminShell.js | 16 +- src/features/admin/components/index.js | 10 +- src/features/admin/index.js | 37 +- src/features/admin/navigation.js | 39 ++ .../admin/pages/DashboardPage.client.js | 27 +- src/features/admin/pages/index.client.js | 19 + src/features/admin/protect.js | 25 +- src/features/admin/registry.js | 84 ++++ src/features/admin/widgets/index.client.js | 5 + src/features/admin/widgets/index.server.js | 3 + src/features/admin/widgets/users.client.js | 19 +- src/features/admin/widgets/users.server.js | 17 +- src/features/auth/AuthPage.client.js | 93 ++++- src/features/auth/AuthPage.server.js | 21 +- src/features/auth/actions.js | 374 +++++++++++++++++- src/features/auth/api.js | 2 +- src/features/auth/auth.js | 8 +- src/features/auth/components/index.js | 39 +- src/features/auth/email.js | 8 +- src/features/auth/index.js | 30 +- src/features/auth/password.js | 2 +- src/features/auth/protect.js | 80 +--- src/features/auth/session.js | 2 +- src/features/init.js | 58 ++- src/shared/components/Loading.js | 2 +- src/shared/components/Modal.js | 2 +- src/shared/components/Table.js | 2 +- src/shared/lib/init.js | 2 +- tsup.config.js | 140 +++---- 35 files changed, 917 insertions(+), 528 deletions(-) create mode 100644 src/features/admin/navigation.js create mode 100644 src/features/admin/pages/index.client.js create mode 100644 src/features/admin/registry.js create mode 100644 src/features/admin/widgets/index.client.js create mode 100644 src/features/admin/widgets/index.server.js diff --git a/docs/DEV.md b/docs/DEV.md index d607faf..4c07cfc 100644 --- a/docs/DEV.md +++ b/docs/DEV.md @@ -36,22 +36,73 @@ Pour les conventions de commit : [COMMITS.md](./dev/COMMITS.md). --- +## Conventions d'arborescence + +Une feature (`src/features//` ou `src/core//`) 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'; ``` --- diff --git a/docs/PROJECT.md b/docs/PROJECT.md index c4728b8..2b4f1f9 100644 --- a/docs/PROJECT.md +++ b/docs/PROJECT.md @@ -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 :** diff --git a/package-lock.json b/package-lock.json index b9f9cf0..d64d352 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index da7b156..c9242bf 100644 --- a/package.json +++ b/package.json @@ -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" } } } diff --git a/src/features/admin/AdminPage.client.js b/src/features/admin/AdminPage.client.js index f21dba4..ac7decf 100644 --- a/src/features/admin/AdminPage.client.js +++ b/src/features/admin/AdminPage.client.js @@ -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 ; + } + if (first === 'roles' && second === 'edit' && third) { + const page = getPage('roles:edit'); + if (page) return ; + } + if (first === 'roles' && second === 'new') { + const page = getPage('roles:edit'); + if (page) return ; + } + + 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 ; + } + return ; +} diff --git a/src/features/admin/AdminPage.server.js b/src/features/admin/AdminPage.server.js index 0238616..3444b07 100644 --- a/src/features/admin/AdminPage.server.js +++ b/src/features/admin/AdminPage.server.js @@ -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 ( - - - + ); } diff --git a/src/features/admin/components/AdminShell.js b/src/features/admin/components/AdminShell.js index 5d4f086..c29d107 100644 --- a/src/features/admin/components/AdminShell.js +++ b/src/features/admin/components/AdminShell.js @@ -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} />
- +
{children} diff --git a/src/features/admin/components/index.js b/src/features/admin/components/index.js index b043e75..47b9170 100644 --- a/src/features/admin/components/index.js +++ b/src/features/admin/components/index.js @@ -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'; diff --git a/src/features/admin/index.js b/src/features/admin/index.js index fb20c9b..64206e3 100644 --- a/src/features/admin/index.js +++ b/src/features/admin/index.js @@ -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'; diff --git a/src/features/admin/navigation.js b/src/features/admin/navigation.js new file mode 100644 index 0000000..4869130 --- /dev/null +++ b/src/features/admin/navigation.js @@ -0,0 +1,39 @@ +import { + registerNavSection, + registerNavItem, + getNavSections, + getNavItems, +} from './registry.js'; + +// Sections et items core — enregistrés à l'import de ce module. +registerNavSection({ id: 'dashboard', title: 'Tableau de bord', icon: 'DashboardSquare03Icon', order: 10 }); +registerNavSection({ id: 'system', title: 'Utilisateurs', icon: 'UserMultiple02Icon', order: 20 }); + +registerNavItem({ id: 'dashboard', label: 'Tableau de bord', icon: 'DashboardSquare03Icon', href: '/admin/dashboard', sectionId: 'dashboard', order: 10 }); +registerNavItem({ id: 'users', label: 'Utilisateurs', icon: 'UserMultiple02Icon', href: '/admin/users', sectionId: 'system', order: 10 }); +registerNavItem({ id: 'roles', label: 'Rôles', icon: 'Crown03Icon', href: '/admin/roles', sectionId: 'system', order: 20 }); + +/** + * Build sections for AdminSidebar. Items are sérialisables (pas de composants), + * icônes en chaînes résolues côté client. + */ +export function buildNavigationSections(pathname) { + const sections = getNavSections(); + const items = getNavItems(); + + const bySection = new Map(); + for (const item of items) { + const list = bySection.get(item.sectionId) || []; + list.push({ + name: item.label, + href: item.href, + icon: item.icon, + current: pathname === item.href || pathname.startsWith(item.href + '/'), + }); + bySection.set(item.sectionId, list); + } + + return sections + .filter(s => bySection.has(s.id)) + .map(s => ({ id: s.id, title: s.title, icon: s.icon, items: bySection.get(s.id) })); +} diff --git a/src/features/admin/pages/DashboardPage.client.js b/src/features/admin/pages/DashboardPage.client.js index 9e5a839..9ff1ec4 100644 --- a/src/features/admin/pages/DashboardPage.client.js +++ b/src/features/admin/pages/DashboardPage.client.js @@ -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 (
-
-
-

- Tableau de bord -

-

Vue d'ensemble de votre application

-
+
+

Tableau de bord

+

Vue d'ensemble de votre application

-
- {sortedWidgets.map(({ id, Component }) => ( - + {widgets.map(({ id, Component }) => ( + ))}
diff --git a/src/features/admin/pages/index.client.js b/src/features/admin/pages/index.client.js new file mode 100644 index 0000000..9ecc750 --- /dev/null +++ b/src/features/admin/pages/index.client.js @@ -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' }); diff --git a/src/features/admin/protect.js b/src/features/admin/protect.js index c7c380d..b7e77de 100644 --- a/src/features/admin/protect.js +++ b/src/features/admin/protect.js @@ -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 }; diff --git a/src/features/admin/registry.js b/src/features/admin/registry.js new file mode 100644 index 0000000..53ed6f4 --- /dev/null +++ b/src/features/admin/registry.js @@ -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/. + * + * 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()]; +} diff --git a/src/features/admin/widgets/index.client.js b/src/features/admin/widgets/index.client.js new file mode 100644 index 0000000..34b1ba8 --- /dev/null +++ b/src/features/admin/widgets/index.client.js @@ -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'; diff --git a/src/features/admin/widgets/index.server.js b/src/features/admin/widgets/index.server.js new file mode 100644 index 0000000..2ca8919 --- /dev/null +++ b/src/features/admin/widgets/index.server.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'; diff --git a/src/features/admin/widgets/users.client.js b/src/features/admin/widgets/users.client.js index c43a45e..1bcb208 100644 --- a/src/features/admin/widgets/users.client.js +++ b/src/features/admin/widgets/users.client.js @@ -1,21 +1,10 @@ 'use client'; -/** - * Widget core — Composant client : nombre d'utilisateurs - * - * Auto-enregistré via admin/dashboard/widgets/index.client.js. - * Pas besoin de modifier features/dashboard.client.js pour ce widget. - * - * Props du composant : - * data — { totalUsers: number, newThisMonth: number } retourné par getUsersDashboardData(), ou null - * loading — true tant que les données serveur ne sont pas disponibles - */ - -import { registerClientWidget } from '../clientRegistry.js'; +import { registerWidget } from '../registry.js'; import { StatCard } from '@zen/core/shared/components'; import { UserMultiple02Icon } from '@zen/core/shared/icons'; -function UsersDashboardWidget({ data, loading }) { +function UsersWidget({ data, loading }) { const newThisMonth = data?.newThisMonth ?? 0; return ( { 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); +}); diff --git a/src/features/auth/AuthPage.client.js b/src/features/auth/AuthPage.client.js index 2b2d80f..205dc68 100644 --- a/src/features/auth/AuthPage.client.js +++ b/src/features/auth/AuthPage.client.js @@ -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 ; + case RegisterPage: + return ; + case ForgotPasswordPage: + return ; + case ResetPasswordPage: + return ; + case ConfirmEmailPage: + return ; + case LogoutPage: + return ; + default: + return null; + } +} diff --git a/src/features/auth/AuthPage.server.js b/src/features/auth/AuthPage.server.js index 2bf7fb6..4e48f27 100644 --- a/src/features/auth/AuthPage.server.js +++ b/src/features/auth/AuthPage.server.js @@ -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,16 +7,16 @@ 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(); - + return (
- :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.' }; + } +} diff --git a/src/features/auth/api.js b/src/features/auth/api.js index 6dd5c2e..59af20a 100644 --- a/src/features/auth/api.js +++ b/src/features/auth/api.js @@ -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'; diff --git a/src/features/auth/auth.js b/src/features/auth/auth.js index b907eca..1a0b59a 100644 --- a/src/features/auth/auth.js +++ b/src/features/auth/auth.js @@ -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 }); } diff --git a/src/features/auth/components/index.js b/src/features/auth/components/index.js index 04d1a7a..b73c2d4 100644 --- a/src/features/auth/components/index.js +++ b/src/features/auth/components/index.js @@ -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'; diff --git a/src/features/auth/email.js b/src/features/auth/email.js index 4483bcb..32e1ed9 100644 --- a/src/features/auth/email.js +++ b/src/features/auth/email.js @@ -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'; diff --git a/src/features/auth/index.js b/src/features/auth/index.js index de7f3ad..bd7f139 100644 --- a/src/features/auth/index.js +++ b/src/features/auth/index.js @@ -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'; diff --git a/src/features/auth/password.js b/src/features/auth/password.js index c0bf7da..8dc234f 100644 --- a/src/features/auth/password.js +++ b/src/features/auth/password.js @@ -1 +1 @@ -export { hashPassword, verifyPassword, generateToken, generateId } from '../../../core/users/password.js'; +export { hashPassword, verifyPassword, generateToken, generateId } from '../../core/users/password.js'; diff --git a/src/features/auth/protect.js b/src/features/auth/protect.js index a809d2a..3a5c237 100644 --- a/src/features/auth/protect.js +++ b/src/features/auth/protect.js @@ -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} 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
Welcome, {session.user.name}!
; - * } - */ -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} 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 ?
Logged in
:
Not logged in
; - * } - */ -async function checkAuth() { - return await getSession(); +export async function checkAuth() { + return getSession(); } -/** - * Require a specific role - * @param {Array} allowedRoles - Array of allowed roles - * @param {Object} options - Options - * @returns {Promise} 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 -}; - diff --git a/src/features/auth/session.js b/src/features/auth/session.js index e8bb5ec..9c8eb21 100644 --- a/src/features/auth/session.js +++ b/src/features/auth/session.js @@ -1 +1 @@ -export { createSession, validateSession, deleteSession, deleteUserSessions, refreshSession } from '../../../core/users/session.js'; +export { createSession, validateSession, deleteSession, deleteUserSessions, refreshSession } from '../../core/users/session.js'; diff --git a/src/features/init.js b/src/features/init.js index 515574a..6e6905e 100644 --- a/src/features/init.js +++ b/src/features/init.js @@ -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} - */ 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; } } diff --git a/src/shared/components/Loading.js b/src/shared/components/Loading.js index 6a10eec..a08f886 100644 --- a/src/shared/components/Loading.js +++ b/src/shared/components/Loading.js @@ -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 = { diff --git a/src/shared/components/Modal.js b/src/shared/components/Modal.js index 855543a..593f498 100644 --- a/src/shared/components/Modal.js +++ b/src/shared/components/Modal.js @@ -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 = ({ diff --git a/src/shared/components/Table.js b/src/shared/components/Table.js index c87938e..6ee0911 100644 --- a/src/shared/components/Table.js +++ b/src/shared/components/Table.js @@ -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' }, diff --git a/src/shared/lib/init.js b/src/shared/lib/init.js index c39f21f..afe6d5c 100644 --- a/src/shared/lib/init.js +++ b/src/shared/lib/init.js @@ -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'; diff --git a/tsup.config.js b/tsup.config.js index 6e387a1..c521495 100644 --- a/tsup.config.js +++ b/tsup.config.js @@ -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'; }, }, ]); -