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