Compare commits
53 Commits
650d2dbb27
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| e783a39ced | |||
| a3aff9fa49 | |||
| 3098940905 | |||
| efc7c93c6b | |||
| 78ba61e60e | |||
| 0d6b06f217 | |||
| 584e96a00d | |||
| 826ce3dcd1 | |||
| ebdeea7287 | |||
| 2360021376 | |||
| 27ebc91d31 | |||
| ab4ecd1ccf | |||
| 2f91a8bcd3 | |||
| 74bc3073a7 | |||
| 01a08b0005 | |||
| 97f8baf502 | |||
| cb8266d9a9 | |||
| 531381430d | |||
| c959b16db5 | |||
| 188e1d82f8 | |||
| 0eee8af8b4 | |||
| 03b24ce320 | |||
| 3b442f2cf5 | |||
| 12c1e36c3c | |||
| 0f199bb5cd | |||
| abd9d651dc | |||
| 96c8cf1e97 | |||
| eff66e0a70 | |||
| ccc6e28d9d | |||
| f481844932 | |||
| 203bd82dd9 | |||
| e1ee9ef564 | |||
| 238666f9cc | |||
| 879fee1b80 | |||
| f46116394c | |||
| f6f2938e3b | |||
| 860d44d728 | |||
| 5218f3f205 | |||
| 1e529a6741 | |||
| dd322bcc86 | |||
| b39e316b4a | |||
| 190664bfbe | |||
| 9138474512 | |||
| 00ea4af242 | |||
| 1032276d49 | |||
| 5f625adc76 | |||
| 310277f5cd | |||
| 4474ab8204 | |||
| bd31d29ac7 | |||
| 4ba9cac007 | |||
| a73357b759 | |||
| b200346d04 | |||
| 759184f0ed |
@@ -10,6 +10,9 @@ ZEN_CURRENCY=CAD
|
||||
ZEN_CURRENCY_SYMBOL=$
|
||||
ZEN_SUPPORT_EMAIL=support@exemple.com
|
||||
|
||||
# PROXY (activer si derrière un reverse proxy)
|
||||
ZEN_TRUST_PROXY=false
|
||||
|
||||
# DATABASE
|
||||
ZEN_DATABASE_URL=postgres://USER:PASSWORD@HOST:PORT/postgres
|
||||
ZEN_DATABASE_URL_DEV=postgres://USER:PASSWORD@HOST:PORT/postgres_dev
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
# Claude Code Rules
|
||||
|
||||
Always read and respect [docs/DEV.md](docs/DEV.md) at the start of every conversation before doing any work in this project.
|
||||
|
||||
After every code change, update the relevant documentation. This includes:
|
||||
- `docs/` for cross-cutting conventions, architecture decisions, and design rules
|
||||
- co-located `README.md` files in `src/core/<module>/` and `src/features/<feature>/` for module-level behaviour
|
||||
|
||||
No task is complete until all impacted documentation is up to date.
|
||||
|
||||
+5
-1
@@ -12,6 +12,8 @@ Pour la procédure de publication du package : [PUBLICATION.md](./dev/PUBLICATIO
|
||||
|
||||
Pour les conventions de commit : [COMMITS.md](./dev/COMMITS.md).
|
||||
|
||||
Pour la création de modules externes `@zen/module-*` : [MODULES.md](./MODULES.md).
|
||||
|
||||
> **Contexte projet** : les utilisateurs finaux créent leur projet via `npx @zen/start`, qui génère automatiquement une structure Next.js avec la plateforme déjà intégré. Lors d'une assistance ou d'une revue, partez du principe que le projet cible est déjà structuré de cette façon.
|
||||
|
||||
---
|
||||
@@ -71,6 +73,8 @@ Ces suffixes ne sont pas cosmétiques : **le build les utilise comme source de v
|
||||
|
||||
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.
|
||||
|
||||
> Pour distribuer ces extensions sous forme de package npm réutilisable plutôt que de les écrire en local, voir [MODULES.md](./MODULES.md) — chaque module `@zen/module-*` installé est auto-découvert et activé.
|
||||
|
||||
```js
|
||||
// app/zen.extensions.js — projet consommateur
|
||||
import {
|
||||
@@ -88,7 +92,7 @@ registerWidgetFetcher('orders', async () => ({ total: await countOrders() }));
|
||||
registerWidget({ id: 'orders', Component: OrdersWidget, order: 20 });
|
||||
|
||||
registerNavSection({ id: 'commerce', title: 'Commerce', icon: 'ShoppingBag03Icon', order: 30 });
|
||||
registerNavItem({ id: 'orders', label: 'Commandes', icon: 'ShoppingBag03Icon', href: '/admin/orders', sectionId: 'commerce' });
|
||||
registerNavItem({ id: 'orders', label: 'Commandes', icon: 'ShoppingBag03Icon', href: '/admin/orders', sectionId: 'commerce', permission: 'orders.view' });
|
||||
|
||||
registerPage({ slug: 'orders', Component: OrdersPage, title: 'Commandes' });
|
||||
```
|
||||
|
||||
+248
@@ -0,0 +1,248 @@
|
||||
# Modules externes `@zen/module-*`
|
||||
|
||||
Un **module** est un package npm distinct qui ajoute des fonctionnalités à un projet construit avec `@zen/core` — sans aucune modification de code dans le projet consommateur.
|
||||
|
||||
```bash
|
||||
npm install @zen/module-billing
|
||||
# ajouter les variables d'env documentées dans le README du module
|
||||
npx zen-db init # crée les tables du module et seed ses permissions
|
||||
npm run dev # tout est câblé : pages admin, sidebar, widgets, API, /zen/<module>/...
|
||||
```
|
||||
|
||||
Aucun fichier de configuration manuelle. La plateforme découvre les modules par scan des dépendances `package.json`.
|
||||
|
||||
---
|
||||
|
||||
## Découverte
|
||||
|
||||
Au boot et au lancement de `zen-db init`, le core scanne `dependencies` + `devDependencies` du `package.json` du projet consommateur et charge tout package matchant :
|
||||
|
||||
- **Préfixe officiel** : `@zen/module-*`
|
||||
- **Préfixe non-scopé** : `zen-module-*`
|
||||
- **Tiers** : tout package dont le `package.json` contient `"keywords": ["zen-module"]`
|
||||
|
||||
Pour chaque module trouvé, le core vérifie qu'il exporte les bons symboles, puis l'enregistre.
|
||||
|
||||
---
|
||||
|
||||
## Forme d'un module
|
||||
|
||||
Le point d'entrée du package (`main` ou `exports["."]`) doit exporter :
|
||||
|
||||
```js
|
||||
// @zen/module-blog/index.js
|
||||
|
||||
export const manifest = {
|
||||
name: '@zen/module-blog',
|
||||
version: '1.0.0',
|
||||
permissions: [
|
||||
{ key: 'blog.view', name: 'Voir les billets', description: 'Consultation', group_name: 'Blog' },
|
||||
{ key: 'blog.manage', name: 'Gérer les billets', description: 'CRUD', group_name: 'Blog' },
|
||||
],
|
||||
envVars: [
|
||||
{ key: 'BLOG_UPLOAD_DIR', required: false, description: 'Répertoire des médias' },
|
||||
],
|
||||
};
|
||||
|
||||
export async function register() {
|
||||
// Tous les enregistrements runtime se font ici (voir API ci-dessous).
|
||||
await import('./register-server.js');
|
||||
}
|
||||
|
||||
export { createTables, dropTables } from './db.js';
|
||||
```
|
||||
|
||||
| Export | Type | Obligatoire |
|
||||
|--------|------|-------------|
|
||||
| `manifest` | objet (voir ci-dessous) | oui |
|
||||
| `register` | `() => void \| Promise<void>` | oui |
|
||||
| `createTables` | `async () => { created?: string[], skipped?: string[] }` | si le module a des tables |
|
||||
| `dropTables` | `async () => void` | si le module a des tables |
|
||||
|
||||
### Manifest
|
||||
|
||||
| Champ | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `name` | `string` | Nom du package (utilisé comme identifiant unique). |
|
||||
| `version` | `string` | Version du module (logguée au boot). |
|
||||
| `permissions` | `Array` | Permissions ajoutées au catalogue. Auto-attribuées au rôle `admin` au prochain `zen-db init`. |
|
||||
| `envVars` | `Array` | Variables d'env du module ; les `required: true` absentes émettent un warning au boot. |
|
||||
|
||||
---
|
||||
|
||||
## API d'enregistrement
|
||||
|
||||
Toutes ces fonctions s'utilisent depuis le hook `register()` du module.
|
||||
|
||||
### Permissions
|
||||
|
||||
Déclarées dans `manifest.permissions`. Le core les enregistre automatiquement avant le seed BD et les attribue au rôle `admin`. À la connexion, l'admin peut les distribuer à d'autres rôles via `/admin/roles`.
|
||||
|
||||
### Sidebar admin
|
||||
|
||||
```js
|
||||
import { registerNavSection, registerNavItem } from '@zen/core/features/admin';
|
||||
|
||||
registerNavSection({ id: 'blog', title: 'Blog', icon: 'Notebook01Icon', order: 40 });
|
||||
registerNavItem({
|
||||
id: 'blog-posts',
|
||||
label: 'Billets',
|
||||
icon: 'Notebook01Icon',
|
||||
href: '/admin/blog',
|
||||
sectionId: 'blog',
|
||||
permission: 'blog.view',
|
||||
});
|
||||
```
|
||||
|
||||
### Pages admin
|
||||
|
||||
```js
|
||||
import { registerPage } from '@zen/core/features/admin';
|
||||
import BlogAdminPage from './admin/BlogAdminPage.client.js';
|
||||
|
||||
registerPage({ slug: 'blog', Component: BlogAdminPage, title: 'Blog' });
|
||||
```
|
||||
|
||||
Rendue sous `/admin/blog`.
|
||||
|
||||
### Widgets dashboard
|
||||
|
||||
```js
|
||||
// côté serveur
|
||||
import { registerWidgetFetcher } from '@zen/core/features/admin';
|
||||
registerWidgetFetcher('blog-posts', async () => ({ count: await countPosts() }));
|
||||
|
||||
// côté client
|
||||
'use client';
|
||||
import { registerWidget } from '@zen/core/features/admin';
|
||||
registerWidget({ id: 'blog-posts', Component: BlogWidget, order: 40, permission: 'blog.view' });
|
||||
```
|
||||
|
||||
### Routes API
|
||||
|
||||
```js
|
||||
import { registerApiRoutes } from '@zen/core/api';
|
||||
import { defineApiRoutes, apiSuccess } from '@zen/core/api';
|
||||
|
||||
const routes = defineApiRoutes([
|
||||
{ path: '/blog/posts', method: 'GET', handler: handleListPosts, auth: 'admin', permission: 'blog.view' },
|
||||
{ path: '/blog/posts', method: 'POST', handler: handleCreatePost, auth: 'admin', permission: 'blog.manage' },
|
||||
{ path: '/blog/posts/:id', method: 'GET', handler: handleGetPost, auth: 'public' },
|
||||
]);
|
||||
|
||||
registerApiRoutes(routes);
|
||||
```
|
||||
|
||||
Le router applique automatiquement la session, le rate-limit et la vérification de permission. Les routes sont accessibles sous `/zen/api/*`.
|
||||
|
||||
| Champ `auth` | Comportement |
|
||||
|--------------|--------------|
|
||||
| `'public'` | Aucune session requise. |
|
||||
| `'user'` | Session valide. |
|
||||
| `'admin'` | Session avec permission `admin.access`. La permission granulaire `permission` est aussi vérifiée si fournie. |
|
||||
|
||||
### Pages publiques `/zen/<module>/...`
|
||||
|
||||
```js
|
||||
import { registerPublicModulePage } from '@zen/core/public-pages';
|
||||
import BlogPublicPage from './public/BlogPublicPage.js';
|
||||
|
||||
registerPublicModulePage({ moduleName: 'blog', Component: BlogPublicPage });
|
||||
```
|
||||
|
||||
URL : `/zen/blog/<...>`. Le composant reçoit `{ params, segments }` :
|
||||
|
||||
```js
|
||||
function BlogPublicPage({ params, segments }) {
|
||||
// /zen/blog/post/abc-123 → segments = ['post', 'abc-123']
|
||||
if (segments[0] === 'post') return <PostView id={segments[1]} />;
|
||||
return <BlogIndex />;
|
||||
}
|
||||
```
|
||||
|
||||
Le namespace `api` est réservé aux routes API et ne peut être utilisé comme `moduleName`.
|
||||
|
||||
### Migrations BD
|
||||
|
||||
```js
|
||||
// db.js
|
||||
import { query, tableExists } from '@zen/core/database';
|
||||
|
||||
const TABLES = [
|
||||
{ name: 'zen_blog_posts', sql: `CREATE TABLE zen_blog_posts (...)` },
|
||||
];
|
||||
|
||||
export async function createTables() {
|
||||
const created = [];
|
||||
const skipped = [];
|
||||
for (const t of TABLES) {
|
||||
if (await tableExists(t.name)) { skipped.push(t.name); continue; }
|
||||
await query(t.sql);
|
||||
created.push(t.name);
|
||||
}
|
||||
return { created, skipped };
|
||||
}
|
||||
|
||||
export async function dropTables() {
|
||||
for (const t of [...TABLES].reverse()) {
|
||||
await query(`DROP TABLE IF EXISTS "${t.name}" CASCADE`);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Convention : préfixer toutes les tables par `zen_<module>_` pour éviter les collisions.
|
||||
|
||||
---
|
||||
|
||||
## Frontières serveur/client
|
||||
|
||||
Comme pour le core (voir [DEV.md](DEV.md)), les fichiers du module portent les suffixes `.server.js` / `.client.js`. Le hook `register()` côté serveur est appelé par le core ; les enregistrements client (widgets, par ex.) doivent être triggés par un import dans le bundle client — typiquement via le composant client lui-même qui appelle `registerWidget()` à l'import.
|
||||
|
||||
```js
|
||||
// Pattern recommandé pour un module avec partie client :
|
||||
|
||||
// src/register-server.js (importé par register())
|
||||
import './admin/BlogAdminPage.client.js'; // chaîne d'imports vers client
|
||||
import './widgets/BlogWidget.server.js';
|
||||
// ... registerNavItem, registerPage, registerApiRoutes, etc.
|
||||
```
|
||||
|
||||
Le bundle Next.js du projet consommateur traverse le graphe d'import et inclut les composants client dans le bundle client. Côté serveur, seules les fonctions et fetchers serveur sont chargés.
|
||||
|
||||
---
|
||||
|
||||
## Variables d'environnement
|
||||
|
||||
Toute variable requise par le module doit être déclarée dans `manifest.envVars` et documentée dans le `README.md` du module. Les variables `required: true` absentes génèrent un warning au boot — elles ne crashent pas le serveur, le module gère son propre fallback.
|
||||
|
||||
---
|
||||
|
||||
## Squelette minimal d'un module
|
||||
|
||||
```
|
||||
@zen/module-blog/
|
||||
├── package.json # name: "@zen/module-blog", main: "./index.js"
|
||||
├── README.md # documente les env vars et la configuration
|
||||
├── index.js # exporte manifest, register, createTables, dropTables
|
||||
├── db.js # createTables/dropTables
|
||||
├── register-server.js # imports déclencheurs (chargé par register())
|
||||
├── api.js # routes API (registerApiRoutes)
|
||||
├── admin/
|
||||
│ ├── BlogAdminPage.client.js # registerPage + composant
|
||||
│ └── widgets/... # registerWidgetFetcher + registerWidget
|
||||
└── public/
|
||||
└── BlogPublicPage.js # registerPublicModulePage
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Cycle de vie complet
|
||||
|
||||
| Étape | Côté core | Côté module |
|
||||
|-------|-----------|-------------|
|
||||
| Install | — | `npm install @zen/module-X` |
|
||||
| Configuration | — | Ajout des `envVars` au `.env` |
|
||||
| Migration BD | `zen-db init` scanne, charge le module, registerPermissions(), seed, createTables() | `createTables()` exécuté |
|
||||
| Boot serveur | `instrumentation.js` → `initializeZen()` scanne, charge, registerPermissions(), `register()` | `register()` exécuté côté serveur |
|
||||
| Premier render client | bundle client traverse les imports → composants client enregistrés | `registerWidget()` exécuté côté client |
|
||||
| Runtime | router dispatch les requêtes, admin résout les pages/widgets via le registre | aucun travail supplémentaire |
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@zen/core",
|
||||
"version": "1.4.98",
|
||||
"version": "1.4.122",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@zen/core",
|
||||
"version": "1.4.98",
|
||||
"version": "1.4.122",
|
||||
"license": "GPL-3.0-only",
|
||||
"dependencies": {
|
||||
"@headlessui/react": "^2.0.0",
|
||||
|
||||
+10
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@zen/core",
|
||||
"version": "1.4.98",
|
||||
"version": "1.4.122",
|
||||
"description": "Une plateforme multi-usage construite sur l'essentiel, rien de plus, rien de moins.",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -93,6 +93,15 @@
|
||||
"./users/constants": {
|
||||
"import": "./dist/core/users/constants.js"
|
||||
},
|
||||
"./modules": {
|
||||
"import": "./dist/core/modules/index.js"
|
||||
},
|
||||
"./public-pages": {
|
||||
"import": "./dist/core/public-pages/index.js"
|
||||
},
|
||||
"./public-pages/server": {
|
||||
"import": "./dist/core/public-pages/PublicModulePage.server.js"
|
||||
},
|
||||
"./api": {
|
||||
"import": "./dist/core/api/index.js"
|
||||
},
|
||||
|
||||
@@ -37,6 +37,7 @@ route-handler.js (GET / POST / PUT / DELETE / PATCH)
|
||||
├─ matchRoute(pattern, path) — exact, :param, /**
|
||||
├─ Auth enforcement (depuis la définition de la route)
|
||||
│ 'admin' → requireAdmin() — session dans context.session
|
||||
│ │ si `permission` est défini → hasPermission() → 403 si refusé
|
||||
│ 'user' → requireAuth() — session dans context.session
|
||||
│ 'public'→ aucun — context.session = undefined
|
||||
└─ handler(request, params, context)
|
||||
@@ -175,6 +176,13 @@ Champs requis par route :
|
||||
| `handler` | `Function` | Signature : `(request, params, context) => Promise<Object>` |
|
||||
| `auth` | `string` | `'public'` \| `'user'` \| `'admin'` |
|
||||
|
||||
Champs optionnels :
|
||||
|
||||
| Champ | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `skipRateLimit` | `boolean` | Exempte la route du rate limiting par IP (ex. health checks) |
|
||||
| `permission` | `string` | Sur une route `auth: 'admin'`, exige en plus cette clé de permission granulaire (ex. `'users.manage'`). Retourne 403 si l'utilisateur ne la possède pas. Voir `PERMISSIONS` dans `src/core/users/constants.js` |
|
||||
|
||||
---
|
||||
|
||||
## Note — handler storage
|
||||
|
||||
@@ -23,6 +23,10 @@
|
||||
* check for this route. Use sparingly — only for routes
|
||||
* that must remain accessible under high probe frequency
|
||||
* (e.g. health checks from monitoring systems).
|
||||
* permission {string} When set on an 'admin' route, the router additionally
|
||||
* verifies that the authenticated user holds this granular
|
||||
* permission key (e.g. 'users.manage'). If the user lacks
|
||||
* the permission, the request is rejected with 403 Forbidden.
|
||||
*
|
||||
* Auth levels:
|
||||
* 'public' Anyone can call this route. context.session is undefined.
|
||||
@@ -77,6 +81,11 @@ export function defineApiRoutes(routes) {
|
||||
`${at} (${route.method} ${route.path}) — "skipRateLimit" must be a boolean when provided, got: ${JSON.stringify(route.skipRateLimit)}`
|
||||
);
|
||||
}
|
||||
if (route.permission !== undefined && typeof route.permission !== 'string') {
|
||||
throw new TypeError(
|
||||
`${at} (${route.method} ${route.path}) — "permission" must be a string when provided, got: ${JSON.stringify(route.permission)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Freeze to prevent accidental mutation of route definitions at runtime.
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
export { routeRequest, requireAuth, requireAdmin } from './router.js';
|
||||
|
||||
// Runtime state — session resolver + feature routes registry
|
||||
export { configureRouter, getSessionResolver, clearRouterConfig, registerFeatureRoutes, getFeatureRoutes, clearFeatureRoutes } from './runtime.js';
|
||||
export { configureRouter, getSessionResolver, clearRouterConfig, registerApiRoutes, registerFeatureRoutes, getFeatureRoutes, clearFeatureRoutes } from './runtime.js';
|
||||
|
||||
// Response utilities — use in all handlers (core and modules)
|
||||
export { apiSuccess, apiError, getStatusCode } from './respond.js';
|
||||
|
||||
@@ -271,6 +271,12 @@ export async function routeRequest(request, path) {
|
||||
try {
|
||||
if (matchedRoute.auth === 'admin') {
|
||||
context.session = await requireAdmin();
|
||||
if (matchedRoute.permission) {
|
||||
const allowed = await hasPermission(context.session.user.id, matchedRoute.permission);
|
||||
if (!allowed) {
|
||||
return apiError('Forbidden', 'Permission insuffisante');
|
||||
}
|
||||
}
|
||||
} else if (matchedRoute.auth === 'user') {
|
||||
context.session = await requireAuth();
|
||||
}
|
||||
|
||||
+11
-4
@@ -68,18 +68,25 @@ if (!globalThis[REGISTRY_KEY]) globalThis[REGISTRY_KEY] = [];
|
||||
const _featureRoutes = globalThis[REGISTRY_KEY];
|
||||
|
||||
/**
|
||||
* Enregistre les routes d'une feature core.
|
||||
* Appelé une fois par feature pendant initializeZen().
|
||||
* Enregistre des routes API.
|
||||
* Appelé une fois par feature core ou module externe pendant initializeZen()
|
||||
* ou depuis le hook register() d'un module.
|
||||
*
|
||||
* @param {ReadonlyArray} routes - Définitions produites par defineApiRoutes()
|
||||
*/
|
||||
export function registerFeatureRoutes(routes) {
|
||||
export function registerApiRoutes(routes) {
|
||||
if (!Array.isArray(routes)) {
|
||||
throw new TypeError('registerFeatureRoutes: routes must be an array');
|
||||
throw new TypeError('registerApiRoutes: routes must be an array');
|
||||
}
|
||||
_featureRoutes.push(...routes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Alias rétro-compatible de registerApiRoutes.
|
||||
* @deprecated Utiliser registerApiRoutes.
|
||||
*/
|
||||
export const registerFeatureRoutes = registerApiRoutes;
|
||||
|
||||
/**
|
||||
* Retourne toutes les routes de features enregistrées.
|
||||
* Appelé à chaque requête par le router pour construire la liste complète.
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
# Cron Framework
|
||||
|
||||
Ce répertoire est un **wrapper générique autour de `node-cron`**. Il ne connaît aucune tâche spécifique — les modules et features enregistrent leurs propres jobs. Ajouter un nouveau job ne nécessite jamais de modifier `src/core/cron/`.
|
||||
|
||||
---
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
src/core/cron/
|
||||
└── index.js schedule, stop, stopAll, trigger, validate, isRunning, getJobs, getStatus
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Import
|
||||
|
||||
```js
|
||||
import { schedule, stop, trigger } from '@zen/core/cron';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API
|
||||
|
||||
### `schedule(name, cronSchedule, handler, options?)`
|
||||
|
||||
Enregistre un job. Si un job du même nom existe déjà, il est stoppé et remplacé.
|
||||
|
||||
```js
|
||||
schedule('daily-report', '0 9 * * *', async () => {
|
||||
await sendReport();
|
||||
});
|
||||
|
||||
schedule('every-5min', '*/5 * * * *', async () => {
|
||||
await syncData();
|
||||
}, { timezone: 'America/New_York', runOnInit: true });
|
||||
```
|
||||
|
||||
| Paramètre | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `name` | `string` | Nom unique du job |
|
||||
| `cronSchedule` | `string` | Expression cron (5 ou 6 champs) |
|
||||
| `handler` | `async Function` | Fonction exécutée à chaque déclenchement |
|
||||
| `options.timezone` | `string` | Timezone IANA (défaut : `ZEN_TIMEZONE` ou `America/Toronto`) |
|
||||
| `options.runOnInit` | `boolean` | Exécuter immédiatement à l'enregistrement (défaut : `false`) |
|
||||
|
||||
Retourne l'instance `node-cron` task.
|
||||
|
||||
---
|
||||
|
||||
### `stop(name)`
|
||||
|
||||
Stoppe et supprime un job par son nom. Retourne `true` si le job existait, `false` sinon.
|
||||
|
||||
### `stopAll()`
|
||||
|
||||
Stoppe et supprime tous les jobs enregistrés.
|
||||
|
||||
### `trigger(name)`
|
||||
|
||||
Déclenche manuellement un job sans attendre son prochain tick. Lève une `Error` si le job n'existe pas.
|
||||
|
||||
```js
|
||||
await trigger('daily-report');
|
||||
```
|
||||
|
||||
### `validate(expression)`
|
||||
|
||||
Valide une expression cron. Retourne `boolean`.
|
||||
|
||||
### `isRunning(name)`
|
||||
|
||||
Vérifie si un job est actuellement enregistré. Retourne `boolean`.
|
||||
|
||||
### `getJobs()`
|
||||
|
||||
Retourne la liste des noms de tous les jobs enregistrés (`string[]`).
|
||||
|
||||
### `getStatus()`
|
||||
|
||||
Retourne les métadonnées de tous les jobs enregistrés.
|
||||
|
||||
```js
|
||||
{
|
||||
'daily-report': {
|
||||
schedule: '0 9 * * *',
|
||||
timezone: 'America/Toronto',
|
||||
registeredAt: '2026-04-24T09:00:00.000Z'
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Enregistrer un job depuis un module
|
||||
|
||||
Les jobs vivent **avec leur feature ou module**, pas dans le framework. Enregistrer un job dans `initializeZen()` (`src/shared/lib/init.js`) :
|
||||
|
||||
```js
|
||||
// src/modules/mymodule/cron.js
|
||||
import { schedule } from '@zen/core/cron';
|
||||
|
||||
export function registerCronJobs() {
|
||||
schedule('mymodule-sync', '*/15 * * * *', async () => {
|
||||
await syncMyModule();
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
```js
|
||||
// src/shared/lib/init.js
|
||||
import { registerCronJobs } from '../../modules/mymodule/cron.js';
|
||||
|
||||
registerCronJobs();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Comportement Hot-Reload
|
||||
|
||||
Les jobs sont stockés dans `globalThis[Symbol.for('__ZEN_CRON_JOBS__')]` — un store partagé qui survit aux invalidations de cache de modules de Next.js. Un job enregistré deux fois (hot-reload) remplace silencieusement l'ancien plutôt que de créer un doublon.
|
||||
|
||||
---
|
||||
|
||||
## Gestion des erreurs
|
||||
|
||||
Les erreurs levées par un handler sont interceptées et loguées via `fail()` — elles ne font jamais crasher le processus.
|
||||
|
||||
```
|
||||
✗ Cron daily-report: Connection timeout
|
||||
```
|
||||
@@ -0,0 +1,155 @@
|
||||
# Email Framework
|
||||
|
||||
Ce répertoire fournit un **wrapper autour de [Resend](https://resend.com)** pour l'envoi d'emails, ainsi qu'un composant de mise en page React Email réutilisable. Il ne connaît aucun template métier — les features créent leurs propres templates et utilisent ce module pour l'envoi.
|
||||
|
||||
---
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
src/core/email/
|
||||
├── index.js sendEmail, sendBatchEmails
|
||||
└── templates/
|
||||
├── index.js re-export
|
||||
└── BaseLayout.js composant de mise en page React Email
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Import
|
||||
|
||||
```js
|
||||
import { sendEmail, sendBatchEmails } from '@zen/core/email';
|
||||
import { BaseLayout } from '@zen/core/email/templates';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Variables d'environnement
|
||||
|
||||
| Variable | Obligatoire | Description |
|
||||
|----------|-------------|-------------|
|
||||
| `ZEN_EMAIL_RESEND_APIKEY` | Oui | Clé API Resend |
|
||||
| `ZEN_EMAIL_FROM_ADDRESS` | Oui | Adresse expéditeur par défaut |
|
||||
| `ZEN_EMAIL_FROM_NAME` | Non | Nom affiché de l'expéditeur |
|
||||
| `ZEN_EMAIL_LOGO` | Non | URL du logo affiché dans `BaseLayout` |
|
||||
| `ZEN_EMAIL_LOGO_URL` | Non | URL de destination du lien autour du logo |
|
||||
| `ZEN_SUPPORT_EMAIL` | Non | Email affiché dans le footer si `supportSection` est activé |
|
||||
| `ZEN_NAME` | Non | Nom de l'application (fallback du nom affiché dans `BaseLayout`) |
|
||||
|
||||
---
|
||||
|
||||
## API
|
||||
|
||||
### `sendEmail(email)`
|
||||
|
||||
Envoie un email via Resend. Retourne `{ success, data, error }`.
|
||||
|
||||
```js
|
||||
const result = await sendEmail({
|
||||
to: 'user@example.com',
|
||||
subject: 'Bienvenue',
|
||||
html: '<p>Bonjour !</p>',
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
console.error(result.error);
|
||||
}
|
||||
```
|
||||
|
||||
| Paramètre | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `to` | `string \| string[]` | Destinataire(s) |
|
||||
| `subject` | `string` | Objet de l'email |
|
||||
| `html` | `string` | Corps HTML |
|
||||
| `text` | `string` | Corps texte brut (optionnel) |
|
||||
| `from` | `string` | Adresse expéditeur (défaut : `ZEN_EMAIL_FROM_ADDRESS`) |
|
||||
| `fromName` | `string` | Nom expéditeur (défaut : `ZEN_EMAIL_FROM_NAME`) |
|
||||
| `replyTo` | `string` | Adresse de réponse (optionnel) |
|
||||
| `attachments` | `object[]` | Pièces jointes Resend (optionnel) |
|
||||
| `tags` | `object[]` | Tags Resend (optionnel) |
|
||||
|
||||
---
|
||||
|
||||
### `sendBatchEmails(emails)`
|
||||
|
||||
Envoie plusieurs emails en une seule requête batch Resend. Retourne `{ success, data, error }`.
|
||||
|
||||
```js
|
||||
await sendBatchEmails([
|
||||
{ to: 'a@example.com', subject: 'Sujet A', html: '<p>A</p>' },
|
||||
{ to: 'b@example.com', subject: 'Sujet B', html: '<p>B</p>' },
|
||||
]);
|
||||
```
|
||||
|
||||
Chaque objet du tableau accepte les mêmes paramètres que `sendEmail`.
|
||||
|
||||
---
|
||||
|
||||
## BaseLayout
|
||||
|
||||
Composant React Email (`@react-email/components`) qui fournit une structure cohérente : logo ou nom de l'app, titre optionnel, contenu, footer avec copyright et lien support.
|
||||
|
||||
```jsx
|
||||
import { render } from '@react-email/render';
|
||||
import { BaseLayout } from '@zen/core/email/templates';
|
||||
|
||||
const html = await render(
|
||||
<BaseLayout
|
||||
preview="Votre commande est confirmée"
|
||||
title="Commande confirmée"
|
||||
supportSection
|
||||
>
|
||||
<Text>Merci pour votre achat.</Text>
|
||||
</BaseLayout>
|
||||
);
|
||||
|
||||
await sendEmail({ to: 'user@example.com', subject: 'Commande confirmée', html });
|
||||
```
|
||||
|
||||
| Prop | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `preview` | `string` | Texte de prévisualisation (snippet email) |
|
||||
| `title` | `string` | Titre affiché en haut du corps |
|
||||
| `children` | `ReactNode` | Contenu de l'email |
|
||||
| `companyName` | `string` | Nom affiché si pas de logo (défaut : `ZEN_NAME` ou `ZEN`) |
|
||||
| `logoURL` | `string` | URL du logo (défaut : `ZEN_EMAIL_LOGO`) |
|
||||
| `supportSection` | `boolean` | Afficher le lien support dans le footer (défaut : `false`) |
|
||||
| `supportEmail` | `string` | Email support (défaut : `ZEN_SUPPORT_EMAIL`) |
|
||||
|
||||
---
|
||||
|
||||
## Créer un template depuis une feature
|
||||
|
||||
Les templates vivent **avec leur feature**, pas dans ce répertoire.
|
||||
|
||||
```jsx
|
||||
// src/features/auth/emails/WelcomeEmail.js
|
||||
import { BaseLayout } from '@zen/core/email/templates';
|
||||
import { Text, Button } from '@react-email/components';
|
||||
|
||||
export const WelcomeEmail = ({ name, loginUrl }) => (
|
||||
<BaseLayout preview={`Bienvenue, ${name}`} title="Bienvenue !">
|
||||
<Text>Bonjour {name}, votre compte est prêt.</Text>
|
||||
<Button href={loginUrl}>Se connecter</Button>
|
||||
</BaseLayout>
|
||||
);
|
||||
```
|
||||
|
||||
```js
|
||||
// src/features/auth/emails/sendWelcome.js
|
||||
import { render } from '@react-email/render';
|
||||
import { sendEmail } from '@zen/core/email';
|
||||
import { WelcomeEmail } from './WelcomeEmail.js';
|
||||
|
||||
export async function sendWelcomeEmail({ to, name, loginUrl }) {
|
||||
const html = await render(<WelcomeEmail name={name} loginUrl={loginUrl} />);
|
||||
return sendEmail({ to, subject: 'Bienvenue !', html });
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Gestion des erreurs
|
||||
|
||||
`sendEmail` et `sendBatchEmails` ne lèvent jamais d'exception — toute erreur est capturée, loguée via `fail()`, et retournée dans `{ success: false, error }`. L'appelant vérifie `result.success`.
|
||||
@@ -0,0 +1,23 @@
|
||||
# Modules
|
||||
|
||||
Registre des modules `@zen/module-*` chargés dans le projet consommateur. Voir [docs/MODULES.md](../../../docs/MODULES.md) pour le guide complet de création d'un module.
|
||||
|
||||
## API
|
||||
|
||||
```js
|
||||
import { registerModule, getRegisteredModules } from '@zen/core/modules';
|
||||
|
||||
// Le core utilise discoverModules() pour peupler ce registre automatiquement.
|
||||
// La plupart des consommateurs n'appellent jamais registerModule() directement.
|
||||
```
|
||||
|
||||
## Forme attendue d'un module
|
||||
|
||||
Le point d'entrée d'un package `@zen/module-X` doit exporter :
|
||||
|
||||
| Export | Type | Obligatoire |
|
||||
|--------|------|-------------|
|
||||
| `manifest` | `{ name, version, permissions?, envVars? }` | oui |
|
||||
| `register` | `() => void \| Promise<void>` | oui |
|
||||
| `createTables` | `async () => { created?, skipped? }` | si le module a des tables |
|
||||
| `dropTables` | `async () => void` | si le module a des tables |
|
||||
@@ -0,0 +1,129 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { resolve, join } from 'node:path';
|
||||
import { createRequire } from 'node:module';
|
||||
import { info, warn, fail } from '@zen/core/shared/logger';
|
||||
import { registerModule, getRegisteredModule } from './registry.js';
|
||||
|
||||
/**
|
||||
* Découverte automatique des modules `@zen/module-*` installés dans le projet consommateur.
|
||||
*
|
||||
* Stratégie :
|
||||
* 1. Lire `package.json` du process.cwd() (le projet consommateur, pas @zen/core).
|
||||
* 2. Pour chaque dépendance dont le nom matche `^@zen/module-` ou `^zen-module-`,
|
||||
* résoudre son point d'entrée et l'importer.
|
||||
* 3. Pour les noms qui ne matchent pas le préfixe, fallback : lire
|
||||
* `keywords` du package.json du package — si "zen-module" est présent, charger.
|
||||
* 4. Valider la forme du module (manifest, register, createTables/dropTables) et
|
||||
* l'enregistrer via registerModule().
|
||||
*
|
||||
* Cette fonction ne lance PAS les hooks register() — elle se contente de découvrir
|
||||
* et d'enregistrer les modules dans le registre. Le boot (initializeZen) et le CLI
|
||||
* (zen-db) consomment ensuite getRegisteredModules() selon leurs besoins.
|
||||
*
|
||||
* Idempotente : appeler plusieurs fois ne charge chaque module qu'une seule fois.
|
||||
*/
|
||||
const NAME_PREFIX = /^(@zen\/module-|zen-module-)/;
|
||||
|
||||
function isCandidate(name) {
|
||||
return NAME_PREFIX.test(name);
|
||||
}
|
||||
|
||||
async function readJson(path) {
|
||||
try {
|
||||
return JSON.parse(await readFile(path, 'utf-8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function isThirdPartyModule(name, projectCwd) {
|
||||
// Fallback pour les modules tiers : on regarde le keywords du package.
|
||||
const require = createRequire(join(projectCwd, 'package.json'));
|
||||
let pkgJsonPath;
|
||||
try {
|
||||
pkgJsonPath = require.resolve(`${name}/package.json`);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
const pkg = await readJson(pkgJsonPath);
|
||||
return Array.isArray(pkg?.keywords) && pkg.keywords.includes('zen-module');
|
||||
}
|
||||
|
||||
async function loadModule(name) {
|
||||
if (getRegisteredModule(name)) return; // déjà chargé
|
||||
|
||||
let mod;
|
||||
try {
|
||||
// Node résout via node_modules à partir du module appelant ; en pratique
|
||||
// depuis dist/core/modules/ dans @zen/core (lui-même installé chez le
|
||||
// consommateur), Node remonte jusqu'aux node_modules du consommateur.
|
||||
mod = await import(name);
|
||||
} catch (error) {
|
||||
fail(`zen-modules: failed to import "${name}" — ${error.message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!mod.manifest || typeof mod.register !== 'function') {
|
||||
warn(`zen-modules: "${name}" missing required exports (manifest, register) — skipping`);
|
||||
return;
|
||||
}
|
||||
|
||||
registerModule({
|
||||
manifest: mod.manifest,
|
||||
register: mod.register,
|
||||
createTables: mod.createTables,
|
||||
dropTables: mod.dropTables,
|
||||
});
|
||||
info(`zen-modules: discovered ${mod.manifest.name}@${mod.manifest.version ?? '?'}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Découvre et enregistre tous les modules installés dans le projet consommateur.
|
||||
*
|
||||
* @param {object} [options]
|
||||
* @param {string} [options.cwd] - Répertoire racine du projet consommateur.
|
||||
* @returns {Promise<{ loaded: string[] }>}
|
||||
*/
|
||||
export async function discoverModules({ cwd = process.cwd() } = {}) {
|
||||
const pkgPath = resolve(cwd, 'package.json');
|
||||
const pkg = await readJson(pkgPath);
|
||||
if (!pkg) {
|
||||
warn(`zen-modules: no package.json at ${pkgPath} — skipping discovery`);
|
||||
return { loaded: [] };
|
||||
}
|
||||
|
||||
const allDeps = {
|
||||
...(pkg.dependencies ?? {}),
|
||||
...(pkg.devDependencies ?? {}),
|
||||
};
|
||||
|
||||
const candidates = [];
|
||||
for (const name of Object.keys(allDeps)) {
|
||||
if (isCandidate(name)) {
|
||||
candidates.push(name);
|
||||
} else if (await isThirdPartyModule(name, cwd)) {
|
||||
candidates.push(name);
|
||||
}
|
||||
}
|
||||
|
||||
for (const name of candidates) {
|
||||
await loadModule(name);
|
||||
}
|
||||
|
||||
return { loaded: candidates };
|
||||
}
|
||||
|
||||
/**
|
||||
* Valide les variables d'environnement requises par chaque module.
|
||||
* Ne lance pas — log un warning pour chaque variable absente.
|
||||
*/
|
||||
export function validateModuleEnvVars(modules) {
|
||||
for (const mod of modules) {
|
||||
const envVars = mod.manifest?.envVars ?? [];
|
||||
for (const v of envVars) {
|
||||
if (v.required && !process.env[v.key]) {
|
||||
warn(`zen-modules: ${mod.manifest.name} requires env var "${v.key}" — ${v.description ?? ''}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { registerModule, getRegisteredModules, getRegisteredModule, clearRegisteredModules } from './registry.js';
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Registre des modules `@zen/module-*` chargés.
|
||||
*
|
||||
* Un module est un package npm exportant :
|
||||
* - manifest : { name, version, permissions?, envVars? }
|
||||
* - register : () => void | Promise<void>
|
||||
* - createTables : async () => { created?: string[], skipped?: string[] }
|
||||
* - dropTables : async () => void
|
||||
*
|
||||
* La découverte (`discover.server.js`) lit le package.json du projet
|
||||
* consommateur et appelle registerModule() pour chaque dépendance détectée.
|
||||
*
|
||||
* Persisté via Symbol.for sur globalThis pour survivre aux hot-reloads.
|
||||
*/
|
||||
|
||||
const REGISTRY_KEY = Symbol.for('__ZEN_MODULES_REGISTRY__');
|
||||
if (!globalThis[REGISTRY_KEY]) globalThis[REGISTRY_KEY] = new Map();
|
||||
/** @type {Map<string, object>} */
|
||||
const registry = globalThis[REGISTRY_KEY];
|
||||
|
||||
export function registerModule(mod) {
|
||||
if (!mod || typeof mod !== 'object') {
|
||||
throw new TypeError('registerModule: argument must be an object');
|
||||
}
|
||||
const { manifest } = mod;
|
||||
if (!manifest || typeof manifest.name !== 'string' || !manifest.name) {
|
||||
throw new TypeError('registerModule: module.manifest.name must be a non-empty string');
|
||||
}
|
||||
if (typeof mod.register !== 'function') {
|
||||
throw new TypeError(`registerModule(${manifest.name}): module.register must be a function`);
|
||||
}
|
||||
registry.set(manifest.name, mod);
|
||||
}
|
||||
|
||||
export function getRegisteredModules() {
|
||||
return [...registry.values()];
|
||||
}
|
||||
|
||||
export function getRegisteredModule(name) {
|
||||
return registry.get(name);
|
||||
}
|
||||
|
||||
export function clearRegisteredModules() {
|
||||
registry.clear();
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
# Payments Framework
|
||||
|
||||
Ce répertoire fournit un **wrapper autour de [Stripe](https://stripe.com)** pour la gestion des paiements : sessions de checkout, intents, clients, remboursements et webhooks.
|
||||
|
||||
---
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
src/core/payments/
|
||||
├── index.js re-export
|
||||
└── stripe.js wrapper Stripe
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Import
|
||||
|
||||
```js
|
||||
import {
|
||||
isEnabled,
|
||||
getPublishableKey,
|
||||
createCheckoutSession,
|
||||
createPaymentIntent,
|
||||
getCheckoutSession,
|
||||
getPaymentIntent,
|
||||
verifyWebhookSignature,
|
||||
createCustomer,
|
||||
getOrCreateCustomer,
|
||||
listPaymentMethods,
|
||||
createRefund,
|
||||
} from '@zen/core/payments';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Variables d'environnement
|
||||
|
||||
| Variable | Obligatoire | Description |
|
||||
|----------|-------------|-------------|
|
||||
| `STRIPE_SECRET_KEY` | Oui | Clé secrète Stripe (côté serveur) |
|
||||
| `STRIPE_PUBLISHABLE_KEY` | Oui | Clé publique Stripe (côté client) |
|
||||
| `STRIPE_WEBHOOK_SECRET` | Pour les webhooks | Secret de signature des webhooks Stripe |
|
||||
| `ZEN_CURRENCY` | Non | Devise par défaut pour les payment intents (défaut : `cad`) |
|
||||
|
||||
---
|
||||
|
||||
## API
|
||||
|
||||
### `isEnabled()`
|
||||
|
||||
Retourne `true` si `STRIPE_SECRET_KEY` et `STRIPE_PUBLISHABLE_KEY` sont définis. Utiliser pour conditionner l'affichage des fonctionnalités de paiement.
|
||||
|
||||
```js
|
||||
if (isEnabled()) {
|
||||
// afficher le bouton de paiement
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `getPublishableKey()`
|
||||
|
||||
Retourne la clé publique Stripe, ou `null` si absente. Passer au client pour initialiser Stripe.js ou `@stripe/react-stripe-js`.
|
||||
|
||||
```js
|
||||
const key = getPublishableKey();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `createCheckoutSession(options)`
|
||||
|
||||
Crée une session Stripe Checkout. Retourne la session Stripe.
|
||||
|
||||
```js
|
||||
const session = await createCheckoutSession({
|
||||
lineItems: [{ price: 'price_xxx', quantity: 1 }],
|
||||
successUrl: 'https://example.com/success',
|
||||
cancelUrl: 'https://example.com/cancel',
|
||||
customerEmail: 'user@example.com',
|
||||
mode: 'payment',
|
||||
});
|
||||
|
||||
// Rediriger l'utilisateur vers session.url
|
||||
```
|
||||
|
||||
| Paramètre | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `lineItems` | `object[]` | Lignes de commande Stripe |
|
||||
| `successUrl` | `string` | URL de retour après paiement réussi |
|
||||
| `cancelUrl` | `string` | URL de retour après annulation |
|
||||
| `customerEmail` | `string` | Email pré-rempli dans le formulaire (optionnel) |
|
||||
| `clientReferenceId` | `string` | Identifiant interne pour rapprochement (optionnel) |
|
||||
| `metadata` | `object` | Métadonnées Stripe (optionnel) |
|
||||
| `mode` | `string` | `'payment'`, `'subscription'` ou `'setup'` (défaut : `'payment'`) |
|
||||
|
||||
---
|
||||
|
||||
### `createPaymentIntent(options)`
|
||||
|
||||
Crée un PaymentIntent Stripe. Retourne le PaymentIntent.
|
||||
|
||||
```js
|
||||
const intent = await createPaymentIntent({
|
||||
amount: 4999, // en centimes
|
||||
currency: 'eur',
|
||||
metadata: { orderId: '123' },
|
||||
});
|
||||
```
|
||||
|
||||
| Paramètre | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `amount` | `number` | Montant en centimes |
|
||||
| `currency` | `string` | Devise ISO (défaut : `ZEN_CURRENCY` ou `cad`) |
|
||||
| `metadata` | `object` | Métadonnées Stripe (optionnel) |
|
||||
| `automaticPaymentMethods` | `object` | Config des méthodes de paiement (défaut : `{ enabled: true }`) |
|
||||
|
||||
---
|
||||
|
||||
### `getCheckoutSession(sessionId)`
|
||||
|
||||
Récupère une session Checkout par son identifiant. À utiliser dans la route `successUrl` pour confirmer le paiement.
|
||||
|
||||
```js
|
||||
const session = await getCheckoutSession(sessionId);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `getPaymentIntent(paymentIntentId)`
|
||||
|
||||
Récupère un PaymentIntent par son identifiant.
|
||||
|
||||
```js
|
||||
const intent = await getPaymentIntent(paymentIntentId);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `verifyWebhookSignature(payload, signature)`
|
||||
|
||||
Vérifie la signature d'un webhook Stripe et retourne l'événement. Lève une erreur si la signature est invalide ou si `STRIPE_WEBHOOK_SECRET` est absent.
|
||||
|
||||
```js
|
||||
// Next.js Route Handler
|
||||
export async function POST(req) {
|
||||
const payload = await req.text();
|
||||
const signature = req.headers.get('stripe-signature');
|
||||
|
||||
let event;
|
||||
try {
|
||||
event = await verifyWebhookSignature(payload, signature);
|
||||
} catch (err) {
|
||||
return new Response('Signature invalide', { status: 400 });
|
||||
}
|
||||
|
||||
if (event.type === 'checkout.session.completed') {
|
||||
// traiter la commande
|
||||
}
|
||||
|
||||
return new Response('OK');
|
||||
}
|
||||
```
|
||||
|
||||
Le `payload` doit être le corps brut de la requête (non parsé).
|
||||
|
||||
---
|
||||
|
||||
### `createCustomer(options)`
|
||||
|
||||
Crée un client Stripe. Retourne le client.
|
||||
|
||||
```js
|
||||
const customer = await createCustomer({
|
||||
email: 'user@example.com',
|
||||
name: 'Jean Dupont',
|
||||
metadata: { userId: '42' },
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `getOrCreateCustomer(email, defaultData)`
|
||||
|
||||
Retourne le client Stripe existant pour cet email, ou en crée un nouveau. Utilise une clé d'idempotence dérivée de l'email pour limiter les doublons en cas d'appels concurrents.
|
||||
|
||||
```js
|
||||
const customer = await getOrCreateCustomer('user@example.com', {
|
||||
name: 'Jean Dupont',
|
||||
metadata: { userId: '42' },
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `listPaymentMethods(customerId, type)`
|
||||
|
||||
Retourne la liste des méthodes de paiement d'un client.
|
||||
|
||||
```js
|
||||
const methods = await listPaymentMethods(customer.id, 'card');
|
||||
```
|
||||
|
||||
Le paramètre `type` est optionnel (défaut : `'card'`).
|
||||
|
||||
---
|
||||
|
||||
### `createRefund(options)`
|
||||
|
||||
Crée un remboursement. Retourne le remboursement Stripe.
|
||||
|
||||
```js
|
||||
const refund = await createRefund({
|
||||
paymentIntentId: 'pi_xxx',
|
||||
amount: 1000, // partiel, en centimes (optionnel — total si absent)
|
||||
reason: 'requested_by_customer', // optionnel
|
||||
});
|
||||
```
|
||||
|
||||
| Paramètre | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `paymentIntentId` | `string` | Identifiant du PaymentIntent à rembourser |
|
||||
| `amount` | `number` | Montant en centimes (optionnel, remboursement total si absent) |
|
||||
| `reason` | `string` | Raison Stripe : `duplicate`, `fraudulent`, `requested_by_customer` (optionnel) |
|
||||
@@ -0,0 +1,142 @@
|
||||
# PDF Framework
|
||||
|
||||
Ce répertoire re-exporte les primitives de [`@react-pdf/renderer`](https://react-pdf.org) et fournit un utilitaire de nommage de fichiers. Il ne contient aucun template métier — les features créent leurs propres documents et utilisent ce module pour le rendu.
|
||||
|
||||
---
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
src/core/pdf/
|
||||
└── index.js re-exports @react-pdf/renderer + getFilename
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Import
|
||||
|
||||
```js
|
||||
import {
|
||||
renderToBuffer,
|
||||
Document,
|
||||
Page,
|
||||
View,
|
||||
Text,
|
||||
Image,
|
||||
Link,
|
||||
StyleSheet,
|
||||
Font,
|
||||
getFilename,
|
||||
} from '@zen/core/pdf';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API
|
||||
|
||||
### `renderToBuffer(element)`
|
||||
|
||||
Rend un document React PDF en `Buffer`. Retourne une `Promise<Buffer>`.
|
||||
|
||||
```js
|
||||
import { renderToBuffer, Document, Page, Text } from '@zen/core/pdf';
|
||||
|
||||
const buffer = await renderToBuffer(
|
||||
<Document>
|
||||
<Page>
|
||||
<Text>Bonjour</Text>
|
||||
</Page>
|
||||
</Document>
|
||||
);
|
||||
```
|
||||
|
||||
Utiliser ce buffer pour servir le PDF en réponse HTTP ou l'écrire sur disque.
|
||||
|
||||
---
|
||||
|
||||
### `getFilename(prefix, identifier, date?)`
|
||||
|
||||
Retourne un nom de fichier normalisé pour un PDF.
|
||||
|
||||
```js
|
||||
getFilename('invoice', '12345')
|
||||
// 'invoice-12345-2024-01-15.pdf'
|
||||
|
||||
getFilename('receipt', 'ORD-99', new Date('2024-06-01'))
|
||||
// 'receipt-ORD-99-2024-06-01.pdf'
|
||||
```
|
||||
|
||||
| Paramètre | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `prefix` | `string` | Type de document (`invoice`, `receipt`, etc.) |
|
||||
| `identifier` | `string` | Identifiant unique (numéro de commande, ID, etc.) |
|
||||
| `date` | `Date` | Date du document (défaut : aujourd'hui) |
|
||||
|
||||
---
|
||||
|
||||
### Primitives re-exportées
|
||||
|
||||
Toutes les primitives de `@react-pdf/renderer` sont disponibles directement depuis `@zen/core/pdf` :
|
||||
|
||||
| Export | Description |
|
||||
|--------|-------------|
|
||||
| `Document` | Racine d'un document PDF |
|
||||
| `Page` | Page du document |
|
||||
| `View` | Conteneur (équivalent `div`) |
|
||||
| `Text` | Bloc de texte |
|
||||
| `Image` | Image (URL ou base64) |
|
||||
| `Link` | Lien hypertexte |
|
||||
| `StyleSheet` | Création de styles (similaire à `StyleSheet.create` React Native) |
|
||||
| `Font` | Enregistrement de polices personnalisées |
|
||||
|
||||
---
|
||||
|
||||
## Créer un template depuis une feature
|
||||
|
||||
Les templates vivent **avec leur feature**, pas dans ce répertoire.
|
||||
|
||||
```jsx
|
||||
// src/features/orders/pdf/InvoiceDocument.js
|
||||
import { Document, Page, View, Text, StyleSheet } from '@zen/core/pdf';
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
page: { padding: 40 },
|
||||
title: { fontSize: 20, marginBottom: 16 },
|
||||
});
|
||||
|
||||
export const InvoiceDocument = ({ order }) => (
|
||||
<Document>
|
||||
<Page style={styles.page}>
|
||||
<Text style={styles.title}>Facture #{order.number}</Text>
|
||||
<Text>{order.customerName}</Text>
|
||||
</Page>
|
||||
</Document>
|
||||
);
|
||||
```
|
||||
|
||||
```js
|
||||
// src/features/orders/pdf/sendInvoice.js
|
||||
import { renderToBuffer, getFilename } from '@zen/core/pdf';
|
||||
import { InvoiceDocument } from './InvoiceDocument.js';
|
||||
|
||||
export async function generateInvoicePdf(order) {
|
||||
const buffer = await renderToBuffer(<InvoiceDocument order={order} />);
|
||||
const filename = getFilename('invoice', order.number);
|
||||
return { buffer, filename };
|
||||
}
|
||||
```
|
||||
|
||||
```js
|
||||
// Next.js Route Handler
|
||||
export async function GET(req, { params }) {
|
||||
const order = await getOrder(params.id);
|
||||
const { buffer, filename } = await generateInvoicePdf(order);
|
||||
|
||||
return new Response(buffer, {
|
||||
headers: {
|
||||
'Content-Type': 'application/pdf',
|
||||
'Content-Disposition': `attachment; filename="${filename}"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,28 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
import { getPublicModulePage } from './registry.js';
|
||||
|
||||
/**
|
||||
* Composant serveur RSC catch-all pour `/zen/<module>/<...>`.
|
||||
*
|
||||
* Next.js route ce composant via un segment `[...path]`. Le premier segment
|
||||
* identifie le module ; le reste est passé au composant enregistré qui fait
|
||||
* son propre routage interne.
|
||||
*
|
||||
* `/zen/api/...` est intercepté en amont par la route API (`route.js`) qui
|
||||
* est plus spécifique pour Next.js — ce composant ne le verra jamais en
|
||||
* pratique, mais on garde le filtre par sûreté.
|
||||
*/
|
||||
export default async function PublicModulePage({ params }) {
|
||||
const resolved = await params;
|
||||
const path = Array.isArray(resolved?.path) ? resolved.path : [];
|
||||
|
||||
if (path.length === 0) notFound();
|
||||
const [moduleName, ...rest] = path;
|
||||
if (moduleName === 'api') notFound();
|
||||
|
||||
const entry = getPublicModulePage(moduleName);
|
||||
if (!entry) notFound();
|
||||
|
||||
const { Component } = entry;
|
||||
return <Component params={resolved} segments={rest} />;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
# Public Module Pages
|
||||
|
||||
Registre runtime pour les pages publiques `/zen/<module>/<...>` ajoutées par les modules externes.
|
||||
|
||||
## Concept
|
||||
|
||||
Tout chemin `/zen/<segment>/...` (sauf `/zen/api/...` réservé aux routes API) est résolu vers le composant enregistré sous `<segment>`. Le module gère son routage interne.
|
||||
|
||||
## API
|
||||
|
||||
```js
|
||||
import { registerPublicModulePage } from '@zen/core/public-pages';
|
||||
|
||||
registerPublicModulePage({
|
||||
moduleName: 'billing',
|
||||
Component: BillingRouter,
|
||||
title: 'Facturation',
|
||||
});
|
||||
```
|
||||
|
||||
Le composant reçoit `{ params, segments }` :
|
||||
|
||||
| Prop | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `params` | `object` | Paramètres Next.js résolus (incluant `path`). |
|
||||
| `segments` | `string[]` | Segments d'URL après `/zen/<moduleName>/`. Le module fait son propre routage. |
|
||||
|
||||
Exemple : `/zen/billing/invoice/abc-123` → `segments = ['invoice', 'abc-123']`.
|
||||
|
||||
## Câblage côté projet consommateur
|
||||
|
||||
Le scaffolder `@zen/start` génère automatiquement `app/zen/[...path]/page.js` qui ré-exporte le composant serveur. Aucune action manuelle requise.
|
||||
|
||||
## Restrictions
|
||||
|
||||
- Le moduleName `api` est réservé et lève une exception à l'enregistrement.
|
||||
- Un seul composant par moduleName ; un appel ultérieur écrase le précédent.
|
||||
@@ -0,0 +1 @@
|
||||
export { registerPublicModulePage, getPublicModulePage, getPublicModulePages } from './registry.js';
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Registre runtime des pages publiques `/zen/<module>/<...>`.
|
||||
*
|
||||
* Chaque module externe enregistre un composant racine pour son namespace.
|
||||
* Le composant reçoit `{ params, segments }` où `segments` est le tableau
|
||||
* de chemins après `/zen/<module>/` ; le module fait son propre routage interne.
|
||||
*
|
||||
* Le préfixe `api` est réservé : tout enregistrement sous moduleName === 'api'
|
||||
* est rejeté pour éviter les collisions avec les routes API.
|
||||
*/
|
||||
|
||||
const REGISTRY_KEY = Symbol.for('__ZEN_PUBLIC_MODULE_PAGES__');
|
||||
if (!globalThis[REGISTRY_KEY]) globalThis[REGISTRY_KEY] = new Map();
|
||||
/** @type {Map<string, { moduleName: string, Component: any, title?: string }>} */
|
||||
const registry = globalThis[REGISTRY_KEY];
|
||||
|
||||
export function registerPublicModulePage({ moduleName, Component, title }) {
|
||||
if (typeof moduleName !== 'string' || !moduleName) {
|
||||
throw new TypeError('registerPublicModulePage: "moduleName" must be a non-empty string');
|
||||
}
|
||||
if (moduleName === 'api') {
|
||||
throw new Error('registerPublicModulePage: "api" is a reserved namespace under /zen/');
|
||||
}
|
||||
if (typeof Component !== 'function' && typeof Component !== 'object') {
|
||||
throw new TypeError(`registerPublicModulePage(${moduleName}): "Component" must be a React component`);
|
||||
}
|
||||
registry.set(moduleName, { moduleName, Component, title });
|
||||
}
|
||||
|
||||
export function getPublicModulePage(moduleName) {
|
||||
return registry.get(moduleName);
|
||||
}
|
||||
|
||||
export function getPublicModulePages() {
|
||||
return [...registry.values()];
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
# Themes
|
||||
|
||||
Ce répertoire gère le thème clair/sombre de l'interface. Il expose des utilitaires client pour lire, appliquer et réagir au thème, ainsi qu'un script d'initialisation à injecter dans `<head>` pour éviter le flash au chargement.
|
||||
|
||||
---
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
src/core/themes/
|
||||
└── index.js THEME_INIT_SCRIPT, getStoredTheme, applyTheme, getThemeIcon, ThemeWatcher, useTheme
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Import
|
||||
|
||||
```js
|
||||
import {
|
||||
THEME_INIT_SCRIPT,
|
||||
getStoredTheme,
|
||||
applyTheme,
|
||||
getThemeIcon,
|
||||
ThemeWatcher,
|
||||
useTheme,
|
||||
} from '@zen/core/themes';
|
||||
```
|
||||
|
||||
Tous les exports sont marqués `'use client'`.
|
||||
|
||||
---
|
||||
|
||||
## API
|
||||
|
||||
### `THEME_INIT_SCRIPT`
|
||||
|
||||
Script inline à injecter dans `<head>` avant le premier rendu. Il lit `localStorage` et applique la classe `dark` sur `<html>` immédiatement, ce qui évite le flash de thème (FOUC).
|
||||
|
||||
```jsx
|
||||
// app/layout.js
|
||||
import { THEME_INIT_SCRIPT } from '@zen/core/themes';
|
||||
|
||||
export default function RootLayout({ children }) {
|
||||
return (
|
||||
<html>
|
||||
<head>
|
||||
<script dangerouslySetInnerHTML={{ __html: THEME_INIT_SCRIPT }} />
|
||||
</head>
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `getStoredTheme()`
|
||||
|
||||
Lit le thème enregistré dans `localStorage`. Retourne `'light'`, `'dark'` ou `'auto'`.
|
||||
|
||||
```js
|
||||
const theme = getStoredTheme(); // 'light' | 'dark' | 'auto'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `applyTheme(theme)`
|
||||
|
||||
Applique un thème en modifiant `document.documentElement` et en mettant à jour `localStorage`. En mode `'auto'`, retire la préférence stockée et suit le système.
|
||||
|
||||
| Valeur | Comportement |
|
||||
|--------|-------------|
|
||||
| `'light'` | Retire la classe `dark`, stocke `'light'` |
|
||||
| `'dark'` | Ajoute la classe `dark`, stocke `'dark'` |
|
||||
| `'auto'` | Retire la valeur stockée, suit `prefers-color-scheme` |
|
||||
|
||||
```js
|
||||
applyTheme('dark');
|
||||
applyTheme('auto');
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `getThemeIcon(theme, systemIsDark)`
|
||||
|
||||
Retourne le composant icône correspondant au thème actuel.
|
||||
|
||||
| Thème | `systemIsDark` | Icône retournée |
|
||||
|-------|----------------|-----------------|
|
||||
| `'light'` | - | `Sun01Icon` |
|
||||
| `'dark'` | - | `Moon02Icon` |
|
||||
| `'auto'` | `true` | `MoonCloudIcon` |
|
||||
| `'auto'` | `false` | `SunCloud01Icon` |
|
||||
|
||||
```jsx
|
||||
const Icon = getThemeIcon(theme, systemIsDark);
|
||||
return <Icon />;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `ThemeWatcher`
|
||||
|
||||
Composant sans rendu qui écoute les changements de `prefers-color-scheme`. Si aucune préférence n'est stockée dans `localStorage`, il met à jour la classe `dark` automatiquement quand le système change.
|
||||
|
||||
```jsx
|
||||
// Placer une fois dans le layout racine, après THEME_INIT_SCRIPT.
|
||||
import { ThemeWatcher } from '@zen/core/themes';
|
||||
|
||||
export default function RootLayout({ children }) {
|
||||
return (
|
||||
<html>
|
||||
<body>
|
||||
<ThemeWatcher />
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `useTheme()`
|
||||
|
||||
Hook qui expose le thème actuel et une fonction de basculement cyclique. Synchronise l'état avec `localStorage` et le système au montage.
|
||||
|
||||
Retourne `{ theme, toggle, systemIsDark }`.
|
||||
|
||||
| Propriété | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `theme` | `'light' \| 'dark' \| 'auto'` | Thème actif |
|
||||
| `toggle` | `() => void` | Passe au thème suivant dans le cycle |
|
||||
| `systemIsDark` | `boolean` | Indique si le système est en mode sombre |
|
||||
|
||||
Le cycle de basculement dépend de la préférence système :
|
||||
- Système clair : `auto` -> `dark` -> `light` -> `auto`
|
||||
- Système sombre : `auto` -> `light` -> `dark` -> `auto`
|
||||
|
||||
```jsx
|
||||
import { useTheme, getThemeIcon } from '@zen/core/themes';
|
||||
|
||||
export function ThemeToggle() {
|
||||
const { theme, toggle, systemIsDark } = useTheme();
|
||||
const Icon = getThemeIcon(theme, systemIsDark);
|
||||
|
||||
return (
|
||||
<button onClick={toggle}>
|
||||
<Icon />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,145 @@
|
||||
# Toast
|
||||
|
||||
Ce répertoire fournit un **système de notifications toast** basé sur un contexte React. Il expose un provider, un hook, et un conteneur à placer dans le layout. Les features utilisent le hook pour déclencher des notifications.
|
||||
|
||||
---
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
src/core/toast/
|
||||
├── index.js Toast, ToastProvider, useToast, ToastContainer
|
||||
├── ToastContext.js contexte, provider, hook useToast
|
||||
├── ToastContainer.js conteneur à monter dans le layout
|
||||
└── Toast.js composant d'affichage individuel
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Import
|
||||
|
||||
```js
|
||||
import { ToastProvider, useToast, ToastContainer } from '@zen/core/toast';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Mise en place
|
||||
|
||||
Entourer le layout avec `ToastProvider` et y placer `ToastContainer`.
|
||||
|
||||
```jsx
|
||||
import { ToastProvider, ToastContainer } from '@zen/core/toast';
|
||||
|
||||
export default function RootLayout({ children }) {
|
||||
return (
|
||||
<ToastProvider>
|
||||
{children}
|
||||
<ToastContainer />
|
||||
</ToastProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
`useToast` lève une erreur si appelé hors du `ToastProvider`.
|
||||
|
||||
---
|
||||
|
||||
## API
|
||||
|
||||
### `useToast()`
|
||||
|
||||
Hook qui expose les méthodes et l'état courant des toasts.
|
||||
|
||||
```js
|
||||
const { success, error, warning, info, addToast, removeToast, clearAllToasts } = useToast();
|
||||
```
|
||||
|
||||
**Méthodes de raccourci**
|
||||
|
||||
```js
|
||||
success('Modifications enregistrées.');
|
||||
error('La connexion a échoué.');
|
||||
warning('Session sur le point d'expirer.');
|
||||
info('Une mise à jour est disponible.');
|
||||
```
|
||||
|
||||
Chaque méthode accepte un message et un objet `options` optionnel pour surcharger les paramètres par défaut.
|
||||
|
||||
```js
|
||||
success('Fichier importé.', { duration: 3000, dismissible: false });
|
||||
```
|
||||
|
||||
Toutes retournent l'`id` du toast créé.
|
||||
|
||||
**`addToast(toast)`**
|
||||
|
||||
Crée un toast à partir d'un objet complet.
|
||||
|
||||
```js
|
||||
const id = addToast({
|
||||
type: 'success',
|
||||
message: 'Profil mis à jour.',
|
||||
title: 'Enregistré',
|
||||
duration: 4000,
|
||||
dismissible: true,
|
||||
});
|
||||
```
|
||||
|
||||
| Paramètre | Type | Défaut | Description |
|
||||
|-----------|------|--------|-------------|
|
||||
| `type` | `'success' \| 'error' \| 'warning' \| 'info'` | `'info'` | Variante visuelle |
|
||||
| `message` | `string` | — | Corps du toast |
|
||||
| `title` | `string` | Selon `type` | Titre affiché (optionnel) |
|
||||
| `duration` | `number` | `5000` | Durée en ms avant disparition automatique. `0` pour désactiver |
|
||||
| `dismissible` | `boolean` | `true` | Afficher le bouton de fermeture |
|
||||
|
||||
Durées par défaut selon le type : `error` → 7000 ms, `warning` → 6000 ms, `success` / `info` → 5000 ms.
|
||||
|
||||
**`removeToast(id)`**
|
||||
|
||||
Supprime un toast immédiatement par son `id`.
|
||||
|
||||
**`clearAllToasts()`**
|
||||
|
||||
Supprime tous les toasts actifs.
|
||||
|
||||
---
|
||||
|
||||
## ToastContainer
|
||||
|
||||
Composant à placer une seule fois dans le layout. Affiche les toasts en bas à droite de l'écran, empilés avec une animation de survol.
|
||||
|
||||
```jsx
|
||||
<ToastContainer maxToasts={5} />
|
||||
```
|
||||
|
||||
| Prop | Type | Défaut | Description |
|
||||
|------|------|--------|-------------|
|
||||
| `maxToasts` | `number` | `5` | Nombre maximum de toasts visibles simultanément |
|
||||
|
||||
Au survol du toast le plus récent, la pile se déploie pour afficher tous les toasts à leur taille réelle.
|
||||
|
||||
---
|
||||
|
||||
## Déclencher un toast depuis une feature
|
||||
|
||||
```js
|
||||
// src/features/auth/actions/login.js
|
||||
import { useToast } from '@zen/core/toast';
|
||||
|
||||
export function useLoginActions() {
|
||||
const { success, error } = useToast();
|
||||
|
||||
async function login(credentials) {
|
||||
const result = await loginRequest(credentials);
|
||||
if (result.success) {
|
||||
success('Connexion réussie.');
|
||||
} else {
|
||||
error('Identifiants incorrects.');
|
||||
}
|
||||
}
|
||||
|
||||
return { login };
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,274 @@
|
||||
# Users
|
||||
|
||||
Ce répertoire gère les utilisateurs, l'authentification par identifiants, les sessions, les rôles et les permissions. Il constitue la couche de données auth du projet : les features l'appellent, il ne connaît pas les features.
|
||||
|
||||
---
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
src/core/users/
|
||||
├── index.js re-exports publics
|
||||
├── auth.js register, login, mot de passe, vérification email
|
||||
├── session.js création, validation, suppression de sessions
|
||||
├── queries.js lecture et mise à jour des utilisateurs
|
||||
├── roles.js CRUD des rôles, assignation aux utilisateurs
|
||||
├── permissions.js hasPermission, getUserPermissions
|
||||
├── constants.js PERMISSIONS, PERMISSION_DEFINITIONS, getPermissionGroups
|
||||
├── verifications.js tokens de vérification email et de réinitialisation
|
||||
├── emailChange.js tokens de changement d'adresse email
|
||||
├── password.js hashPassword, verifyPassword, generateToken, generateId
|
||||
└── db.js helpers internes
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Import
|
||||
|
||||
```js
|
||||
import {
|
||||
register, login, requestPasswordReset, resetPassword, verifyUserEmail, updateUser,
|
||||
createSession, validateSession, deleteSession, deleteUserSessions, refreshSession,
|
||||
getUserById, getUserByEmail, countUsers, listUsers, updateUserById,
|
||||
createRole, updateRole, deleteRole, listRoles, getRoleById,
|
||||
getUserRoles, assignUserRole, revokeUserRole,
|
||||
hasPermission, getUserPermissions,
|
||||
PERMISSIONS, PERMISSION_DEFINITIONS, getPermissionGroups,
|
||||
hashPassword, verifyPassword, generateToken, generateId,
|
||||
} from '@zen/core/users';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API
|
||||
|
||||
### `register(userData, options?)`
|
||||
|
||||
Crée un compte utilisateur avec vérification des contraintes mot de passe. Le premier utilisateur enregistré reçoit le rôle `admin`. Retourne `{ user, verificationToken }`.
|
||||
|
||||
```js
|
||||
const { user, verificationToken } = await register(
|
||||
{ email: 'alice@example.com', password: 'Secret1', name: 'Alice' },
|
||||
{
|
||||
onEmailVerification: async (email, token) => {
|
||||
await sendVerificationEmail({ to: email, token });
|
||||
},
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
| Paramètre | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `email` | `string` | Adresse email (max 254 caractères) |
|
||||
| `password` | `string` | Mot de passe (8-128 caractères, au moins 1 majuscule, 1 minuscule, 1 chiffre) |
|
||||
| `name` | `string` | Nom affiché (max 100 caractères) |
|
||||
| `onEmailVerification` | `async (email, token) => void` | Callback pour envoyer le token de vérification |
|
||||
|
||||
---
|
||||
|
||||
### `login(credentials, sessionOptions?)`
|
||||
|
||||
Vérifie les identifiants et crée une session. Retourne `{ user, session }`. Lève une erreur si les identifiants sont incorrects.
|
||||
|
||||
```js
|
||||
const { user, session } = await login(
|
||||
{ email: 'alice@example.com', password: 'Secret1' },
|
||||
{ ipAddress: req.ip, userAgent: req.headers['user-agent'] }
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `requestPasswordReset(email)`
|
||||
|
||||
Génère un token de réinitialisation (expire dans 1 heure). Retourne `{ success: true, token }` même si l'email est inconnu, pour éviter l'énumération.
|
||||
|
||||
```js
|
||||
const { token } = await requestPasswordReset('alice@example.com');
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `resetPassword(data, options?)`
|
||||
|
||||
Valide le token et met à jour le mot de passe. Retourne `{ success: true }`.
|
||||
|
||||
```js
|
||||
await resetPassword(
|
||||
{ email: 'alice@example.com', token, newPassword: 'NewSecret1' },
|
||||
{ onPasswordChanged: async (email) => { /* envoyer confirmation */ } }
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `verifyUserEmail(userId)`
|
||||
|
||||
Marque l'email de l'utilisateur comme vérifié.
|
||||
|
||||
```js
|
||||
await verifyUserEmail(user.id);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `updateUser(userId, data)`
|
||||
|
||||
Met à jour les champs autorisés du profil : `name`, `image`, `language`.
|
||||
|
||||
```js
|
||||
await updateUser(user.id, { name: 'Alice Martin' });
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `createSession(userId, options?)`
|
||||
|
||||
Crée une session valide 30 jours. Retourne l'objet session.
|
||||
|
||||
```js
|
||||
const session = await createSession(user.id, { ipAddress: '127.0.0.1', userAgent: '...' });
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `validateSession(token)`
|
||||
|
||||
Valide un token de session. Renouvelle automatiquement la session si elle expire dans moins de 20 jours. Retourne `{ session, user, sessionRefreshed }` ou `null`.
|
||||
|
||||
```js
|
||||
const result = await validateSession(token);
|
||||
if (!result) {
|
||||
// session expirée ou invalide
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `deleteSession(token)` / `deleteUserSessions(userId)`
|
||||
|
||||
Supprime une session ou toutes les sessions d'un utilisateur.
|
||||
|
||||
```js
|
||||
await deleteSession(token);
|
||||
await deleteUserSessions(user.id);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `getUserById(id)` / `getUserByEmail(email)`
|
||||
|
||||
Récupère un utilisateur par son id ou son email.
|
||||
|
||||
```js
|
||||
const user = await getUserById('abc123');
|
||||
const user = await getUserByEmail('alice@example.com');
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `listUsers(options?)`
|
||||
|
||||
Liste les utilisateurs avec pagination et tri. Retourne `{ users, pagination }`.
|
||||
|
||||
```js
|
||||
const { users, pagination } = await listUsers({ page: 1, limit: 20, sortBy: 'created_at', sortOrder: 'desc' });
|
||||
```
|
||||
|
||||
| Paramètre | Défaut | Description |
|
||||
|-----------|--------|-------------|
|
||||
| `page` | `1` | Page courante |
|
||||
| `limit` | `10` | Résultats par page (max 100) |
|
||||
| `sortBy` | `'created_at'` | Colonne de tri (`id`, `email`, `name`, `role`, `email_verified`, `created_at`) |
|
||||
| `sortOrder` | `'desc'` | `'asc'` ou `'desc'` |
|
||||
|
||||
---
|
||||
|
||||
### `updateUserById(id, fields)`
|
||||
|
||||
Met à jour les champs autorisés d'un utilisateur : `name`, `role`, `email_verified`, `image`, `language`.
|
||||
|
||||
```js
|
||||
await updateUserById(user.id, { role: 'editor', email_verified: true });
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Rôles
|
||||
|
||||
```js
|
||||
const roles = await listRoles();
|
||||
const role = await getRoleById(id);
|
||||
|
||||
const role = await createRole({ name: 'Modérateur', description: 'Peut gérer les utilisateurs', color: '#3b82f6' });
|
||||
|
||||
await updateRole(roleId, {
|
||||
name: 'Modérateur',
|
||||
permissionKeys: [PERMISSIONS.USERS_VIEW, PERMISSIONS.USERS_MANAGE],
|
||||
});
|
||||
|
||||
await deleteRole(roleId); // impossible sur les rôles système
|
||||
|
||||
const userRoles = await getUserRoles(userId);
|
||||
await assignUserRole(userId, roleId);
|
||||
await revokeUserRole(userId, roleId);
|
||||
```
|
||||
|
||||
Les rôles système (`is_system = true`) peuvent être renommés mais leurs permissions ne peuvent pas être modifiées. Ils ne peuvent pas être supprimés.
|
||||
|
||||
L'endpoint `DELETE /zen/api/users/:id/roles/:roleId` applique une règle de sécurité supplémentaire : un utilisateur ne peut pas se retirer un rôle qui lui accorde `users.manage` s'il n'en a pas d'autre. Cela évite qu'un administrateur se retrouve dans l'impossibilité de se redonner la permission. Cette vérification est faite au niveau du handler API et ne concerne pas la fonction `revokeUserRole` elle-même.
|
||||
|
||||
---
|
||||
|
||||
### Permissions
|
||||
|
||||
```js
|
||||
const canManageRoles = await hasPermission(userId, PERMISSIONS.ROLES_MANAGE);
|
||||
const keys = await getUserPermissions(userId);
|
||||
```
|
||||
|
||||
`PERMISSIONS` contient toutes les clés disponibles. `PERMISSION_DEFINITIONS` expose le label, la description et le groupe de chaque permission. `getPermissionGroups()` retourne les permissions regroupées par `group_name`.
|
||||
|
||||
| Groupe | Clés |
|
||||
|--------|------|
|
||||
| Administration | `admin.access` |
|
||||
| Utilisateurs | `users.view`, `users.manage` |
|
||||
| Rôles | `roles.view`, `roles.manage` |
|
||||
|
||||
---
|
||||
|
||||
### Changement d'email
|
||||
|
||||
```js
|
||||
import { createEmailChangeToken, verifyEmailChangeToken, applyEmailChange } from '@zen/core/users/emailChange';
|
||||
|
||||
const token = await createEmailChangeToken(userId, 'new@example.com');
|
||||
|
||||
// Plus tard, lors de la confirmation :
|
||||
const result = await verifyEmailChangeToken(token);
|
||||
if (result) {
|
||||
await applyEmailChange(result.userId, result.newEmail);
|
||||
}
|
||||
```
|
||||
|
||||
Le token expire dans 24 heures. `verifyEmailChangeToken` retourne `null` si le token est invalide ou expiré.
|
||||
|
||||
---
|
||||
|
||||
## Gestion des erreurs
|
||||
|
||||
`register`, `login`, `resetPassword` lèvent des erreurs typées (`Error`) avec des messages en français. Les fonctions de requête (`getUserById`, etc.) retournent `null` si l'entrée n'existe pas. Les callbacks `onEmailVerification` et `onPasswordChanged` sont exécutés sans bloquer le flux principal : une erreur dans le callback est loguée mais n'interrompt pas l'opération.
|
||||
|
||||
---
|
||||
|
||||
## Tables utilisées
|
||||
|
||||
| Table | Description |
|
||||
|-------|-------------|
|
||||
| `zen_auth_users` | Comptes utilisateurs |
|
||||
| `zen_auth_accounts` | Identifiants par provider (`credential`) |
|
||||
| `zen_auth_sessions` | Sessions actives |
|
||||
| `zen_auth_verifications` | Tokens de vérification email, reset mot de passe, changement email |
|
||||
| `zen_auth_roles` | Rôles |
|
||||
| `zen_auth_role_permissions` | Permissions associées aux rôles |
|
||||
| `zen_auth_user_roles` | Rôles assignés aux utilisateurs |
|
||||
+66
-2
@@ -2,7 +2,7 @@ import { query, create, findOne, updateById, count } from '@zen/core/database';
|
||||
import { hashPassword, verifyPassword, generateId } from './password.js';
|
||||
import { createSession } from './session.js';
|
||||
import { fail } from '@zen/core/shared/logger';
|
||||
import { createEmailVerification, createPasswordReset, verifyResetToken, deleteResetToken } from './verifications.js';
|
||||
import { createEmailVerification, createPasswordReset, verifyResetToken, deleteResetToken, verifyAccountSetupToken, deleteAccountSetupToken } from './verifications.js';
|
||||
|
||||
async function register(userData, { onEmailVerification } = {}) {
|
||||
const { email, password, name } = userData;
|
||||
@@ -228,4 +228,68 @@ async function updateUser(userId, updateData) {
|
||||
return await updateById('zen_auth_users', userId, filteredData);
|
||||
}
|
||||
|
||||
export { register, login, requestPasswordReset, resetPassword, verifyUserEmail, updateUser };
|
||||
async function completeAccountSetup({ email, token, password }) {
|
||||
if (!email || !token || !password) {
|
||||
throw new Error('L\'e-mail, le jeton et le mot de passe sont requis');
|
||||
}
|
||||
|
||||
if (password.length < 8) {
|
||||
throw new Error('Le mot de passe doit contenir au moins 8 caractères');
|
||||
}
|
||||
|
||||
if (password.length > 128) {
|
||||
throw new Error('Le mot de passe doit contenir 128 caractères ou moins');
|
||||
}
|
||||
|
||||
const hasUppercase = /[A-Z]/.test(password);
|
||||
const hasLowercase = /[a-z]/.test(password);
|
||||
const hasNumber = /\d/.test(password);
|
||||
|
||||
if (!hasUppercase || !hasLowercase || !hasNumber) {
|
||||
throw new Error('Le mot de passe doit contenir au moins une majuscule, une minuscule et un chiffre');
|
||||
}
|
||||
|
||||
const tokenValid = await verifyAccountSetupToken(email, token);
|
||||
if (!tokenValid) {
|
||||
throw new Error('Lien d\'invitation invalide ou expiré');
|
||||
}
|
||||
|
||||
const user = await findOne('zen_auth_users', { email });
|
||||
if (!user) {
|
||||
throw new Error('Lien d\'invitation invalide');
|
||||
}
|
||||
|
||||
const hashedPassword = await hashPassword(password);
|
||||
|
||||
const existingAccount = await findOne('zen_auth_accounts', {
|
||||
user_id: user.id,
|
||||
provider_id: 'credential'
|
||||
});
|
||||
|
||||
if (existingAccount) {
|
||||
await updateById('zen_auth_accounts', existingAccount.id, {
|
||||
password: hashedPassword,
|
||||
updated_at: new Date()
|
||||
});
|
||||
} else {
|
||||
await create('zen_auth_accounts', {
|
||||
id: generateId(),
|
||||
account_id: email,
|
||||
provider_id: 'credential',
|
||||
user_id: user.id,
|
||||
password: hashedPassword,
|
||||
updated_at: new Date()
|
||||
});
|
||||
}
|
||||
|
||||
await updateById('zen_auth_users', user.id, {
|
||||
email_verified: true,
|
||||
updated_at: new Date()
|
||||
});
|
||||
|
||||
await deleteAccountSetupToken(email);
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export { register, login, requestPasswordReset, resetPassword, verifyUserEmail, updateUser, completeAccountSetup };
|
||||
|
||||
+10
-32
@@ -4,41 +4,19 @@
|
||||
*/
|
||||
|
||||
export const PERMISSIONS = {
|
||||
ADMIN_ACCESS: 'admin.access',
|
||||
CONTENT_VIEW: 'content.view',
|
||||
CONTENT_CREATE: 'content.create',
|
||||
CONTENT_EDIT: 'content.edit',
|
||||
CONTENT_DELETE: 'content.delete',
|
||||
CONTENT_PUBLISH: 'content.publish',
|
||||
MEDIA_VIEW: 'media.view',
|
||||
MEDIA_UPLOAD: 'media.upload',
|
||||
MEDIA_DELETE: 'media.delete',
|
||||
USERS_VIEW: 'users.view',
|
||||
USERS_EDIT: 'users.edit',
|
||||
USERS_DELETE: 'users.delete',
|
||||
ROLES_VIEW: 'roles.view',
|
||||
ROLES_MANAGE: 'roles.manage',
|
||||
SETTINGS_VIEW: 'settings.view',
|
||||
SETTINGS_MANAGE: 'settings.manage',
|
||||
ADMIN_ACCESS: 'admin.access',
|
||||
USERS_VIEW: 'users.view',
|
||||
USERS_MANAGE: 'users.manage',
|
||||
ROLES_VIEW: 'roles.view',
|
||||
ROLES_MANAGE: 'roles.manage',
|
||||
};
|
||||
|
||||
export const PERMISSION_DEFINITIONS = [
|
||||
{ key: 'admin.access', name: 'Accès au panneau admin', description: "Permet d'accéder à l'interface d'administration.", group_name: 'Administration' },
|
||||
{ key: 'content.view', name: 'Voir le contenu', description: 'Permet de consulter les articles, pages et autres contenus.', group_name: 'Contenu' },
|
||||
{ key: 'content.create', name: 'Créer du contenu', description: 'Permet de rédiger et soumettre de nouveaux contenus.', group_name: 'Contenu' },
|
||||
{ key: 'content.edit', name: 'Modifier le contenu', description: 'Permet de mettre à jour des contenus existants.', group_name: 'Contenu' },
|
||||
{ key: 'content.delete', name: 'Supprimer le contenu', description: 'Permet de supprimer définitivement des contenus.', group_name: 'Contenu' },
|
||||
{ key: 'content.publish', name: 'Publier le contenu', description: 'Permet de rendre des contenus visibles publiquement.', group_name: 'Contenu' },
|
||||
{ key: 'media.view', name: 'Voir les médias', description: 'Permet de parcourir la médiathèque.', group_name: 'Médias' },
|
||||
{ key: 'media.upload', name: 'Téléverser des médias', description: 'Permet d\'uploader des images, vidéos et fichiers.', group_name: 'Médias' },
|
||||
{ key: 'media.delete', name: 'Supprimer des médias', description: 'Permet de supprimer des fichiers de la médiathèque.', group_name: 'Médias' },
|
||||
{ key: 'users.view', name: 'Voir les utilisateurs', description: 'Permet de consulter la liste des membres et leurs profils.', group_name: 'Utilisateurs' },
|
||||
{ key: 'users.edit', name: 'Modifier les utilisateurs', description: 'Permet de changer les informations et les rôles des membres.', group_name: 'Utilisateurs' },
|
||||
{ key: 'users.delete', name: 'Supprimer des utilisateurs', description: 'Permet de supprimer des comptes membres.', group_name: 'Utilisateurs' },
|
||||
{ key: 'roles.view', name: 'Voir les rôles', description: 'Permet de consulter la liste des rôles et leurs permissions.', group_name: 'Rôles' },
|
||||
{ key: 'roles.manage', name: 'Gérer les rôles', description: 'Permet de créer, modifier et supprimer des rôles.', group_name: 'Rôles' },
|
||||
{ key: 'settings.view', name: 'Voir les paramètres', description: 'Permet de consulter la configuration du site.', group_name: 'Paramètres' },
|
||||
{ key: 'settings.manage', name: 'Gérer les paramètres', description: 'Permet de modifier la configuration et les réglages du site.', group_name: 'Paramètres' },
|
||||
{ key: 'admin.access', name: 'Accès au panneau admin', description: "Permet d'accéder à l'interface d'administration.", group_name: 'Administration' },
|
||||
{ key: 'users.view', name: 'Voir les utilisateurs', description: 'Permet de consulter la liste des membres et leurs profils.', group_name: 'Utilisateurs' },
|
||||
{ key: 'users.manage', name: 'Gérer les utilisateurs', description: 'Permet de créer, modifier et supprimer des comptes membres.', group_name: 'Utilisateurs' },
|
||||
{ key: 'roles.view', name: 'Voir les rôles', description: 'Permet de consulter la liste des rôles et leurs permissions.', group_name: 'Rôles' },
|
||||
{ key: 'roles.manage', name: 'Gérer les rôles', description: 'Permet de créer, modifier et supprimer des rôles.', group_name: 'Rôles' },
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
+36
-11
@@ -2,8 +2,9 @@ import { query, tableExists } from '@zen/core/database';
|
||||
import { generateId } from './password.js';
|
||||
import { done, warn } from '@zen/core/shared/logger';
|
||||
import { PERMISSION_DEFINITIONS } from './constants.js';
|
||||
import { registerPermissions, getRegisteredPermissions } from './permissions-registry.js';
|
||||
|
||||
const USER_ROLE_PERMISSIONS = ['content.view', 'media.view'];
|
||||
const USER_ROLE_PERMISSIONS = [];
|
||||
|
||||
const ROLE_TABLES = [
|
||||
{
|
||||
@@ -66,15 +67,37 @@ async function dropRoleCheckConstraint() {
|
||||
`);
|
||||
}
|
||||
|
||||
async function migratePermissions() {
|
||||
// Migrate users.edit / users.delete → users.manage
|
||||
await query(`
|
||||
INSERT INTO zen_auth_role_permissions (role_id, permission_key)
|
||||
SELECT DISTINCT role_id, 'users.manage'
|
||||
FROM zen_auth_role_permissions
|
||||
WHERE permission_key IN ('users.edit', 'users.delete')
|
||||
AND EXISTS (SELECT 1 FROM zen_auth_permissions WHERE key = 'users.manage')
|
||||
ON CONFLICT DO NOTHING
|
||||
`);
|
||||
await query(`DELETE FROM zen_auth_role_permissions WHERE permission_key IN ('users.edit', 'users.delete')`);
|
||||
await query(`DELETE FROM zen_auth_permissions WHERE key IN ('users.edit', 'users.delete')`);
|
||||
}
|
||||
|
||||
async function seedDefaultRolesAndPermissions() {
|
||||
// Permissions
|
||||
for (const perm of PERMISSION_DEFINITIONS) {
|
||||
// S'assure que les permissions core sont dans le registre, puis seed depuis
|
||||
// le registre — qui contient core + permissions enregistrées par les modules.
|
||||
registerPermissions(PERMISSION_DEFINITIONS);
|
||||
const allPermissions = getRegisteredPermissions();
|
||||
|
||||
for (const perm of allPermissions) {
|
||||
await query(
|
||||
`INSERT INTO zen_auth_permissions (key, name, group_name) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING`,
|
||||
[perm.key, perm.name, perm.group_name]
|
||||
`INSERT INTO zen_auth_permissions (key, name, description, group_name)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (key) DO UPDATE SET name = EXCLUDED.name, description = EXCLUDED.description, group_name = EXCLUDED.group_name`,
|
||||
[perm.key, perm.name, perm.description, perm.group_name]
|
||||
);
|
||||
}
|
||||
|
||||
await migratePermissions();
|
||||
|
||||
// Admin role
|
||||
const adminRoleId = generateId();
|
||||
await query(
|
||||
@@ -84,12 +107,14 @@ async function seedDefaultRolesAndPermissions() {
|
||||
const adminRole = await query(`SELECT id FROM zen_auth_roles WHERE name = 'admin'`);
|
||||
const adminId = adminRole.rows[0].id;
|
||||
|
||||
for (const perm of PERMISSION_DEFINITIONS) {
|
||||
await query(
|
||||
`INSERT INTO zen_auth_role_permissions (role_id, permission_key) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
|
||||
[adminId, perm.key]
|
||||
);
|
||||
}
|
||||
// Toute permission présente dans le catalogue est attribuée au rôle admin —
|
||||
// y compris les permissions ajoutées par les modules après le premier init.
|
||||
await query(
|
||||
`INSERT INTO zen_auth_role_permissions (role_id, permission_key)
|
||||
SELECT $1, key FROM zen_auth_permissions
|
||||
ON CONFLICT DO NOTHING`,
|
||||
[adminId]
|
||||
);
|
||||
|
||||
// User role
|
||||
const userRoleId = generateId();
|
||||
|
||||
+11
-1
@@ -4,4 +4,14 @@ export { createSession, validateSession, deleteSession, deleteUserSessions, refr
|
||||
export { createEmailVerification, verifyEmailToken, createPasswordReset, verifyResetToken, deleteResetToken } from './verifications.js';
|
||||
export { hashPassword, verifyPassword, generateToken, generateId } from './password.js';
|
||||
export { listRoles, getRoleById, createRole, updateRole, deleteRole, getUserRoles, assignUserRole, revokeUserRole } from './roles.js';
|
||||
export { PERMISSIONS, PERMISSION_DEFINITIONS, getPermissionGroups, hasPermission, getUserPermissions } from './permissions.js';
|
||||
export {
|
||||
PERMISSIONS,
|
||||
PERMISSION_DEFINITIONS,
|
||||
getPermissionGroups,
|
||||
hasPermission,
|
||||
getUserPermissions,
|
||||
registerPermission,
|
||||
registerPermissions,
|
||||
getRegisteredPermissions,
|
||||
getRegisteredPermissionKeys,
|
||||
} from './permissions.js';
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Registre runtime des permissions.
|
||||
*
|
||||
* Le core enregistre ses permissions au boot (initializeZen) ; chaque module
|
||||
* externe enregistre les siennes via son hook register(). Le registre alimente
|
||||
* à la fois le seed BD (zen-db init) et la validation runtime (updateRole).
|
||||
*
|
||||
* Le registre est un singleton process-local persisté via Symbol.for sur
|
||||
* globalThis pour survivre aux hot-reloads Next.js.
|
||||
*/
|
||||
|
||||
const REGISTRY_KEY = Symbol.for('__ZEN_PERMISSIONS_REGISTRY__');
|
||||
if (!globalThis[REGISTRY_KEY]) globalThis[REGISTRY_KEY] = new Map();
|
||||
/** @type {Map<string, { key: string, name: string, description?: string, group_name: string }>} */
|
||||
const registry = globalThis[REGISTRY_KEY];
|
||||
|
||||
export function registerPermission({ key, name, description, group_name }) {
|
||||
if (typeof key !== 'string' || !key) {
|
||||
throw new TypeError('registerPermission: "key" must be a non-empty string');
|
||||
}
|
||||
if (typeof name !== 'string' || !name) {
|
||||
throw new TypeError(`registerPermission(${key}): "name" must be a non-empty string`);
|
||||
}
|
||||
if (typeof group_name !== 'string' || !group_name) {
|
||||
throw new TypeError(`registerPermission(${key}): "group_name" must be a non-empty string`);
|
||||
}
|
||||
registry.set(key, { key, name, description: description ?? null, group_name });
|
||||
}
|
||||
|
||||
export function registerPermissions(list) {
|
||||
if (!Array.isArray(list)) {
|
||||
throw new TypeError('registerPermissions: argument must be an array');
|
||||
}
|
||||
for (const perm of list) registerPermission(perm);
|
||||
}
|
||||
|
||||
export function getRegisteredPermissions() {
|
||||
return [...registry.values()];
|
||||
}
|
||||
|
||||
export function getRegisteredPermissionKeys() {
|
||||
return new Set(registry.keys());
|
||||
}
|
||||
|
||||
export function clearRegisteredPermissions() {
|
||||
registry.clear();
|
||||
}
|
||||
@@ -1,5 +1,11 @@
|
||||
import { query } from '@zen/core/database';
|
||||
export { PERMISSIONS, PERMISSION_DEFINITIONS, getPermissionGroups } from './constants.js';
|
||||
export {
|
||||
registerPermission,
|
||||
registerPermissions,
|
||||
getRegisteredPermissions,
|
||||
getRegisteredPermissionKeys,
|
||||
} from './permissions-registry.js';
|
||||
|
||||
export async function hasPermission(userId, permissionKey) {
|
||||
const result = await query(
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { query, transaction } from '@zen/core/database';
|
||||
import { generateId } from './password.js';
|
||||
import { PERMISSIONS } from './permissions.js';
|
||||
|
||||
const VALID_PERMISSION_KEYS = new Set(Object.values(PERMISSIONS));
|
||||
import { getRegisteredPermissionKeys } from './permissions-registry.js';
|
||||
|
||||
export async function listRoles() {
|
||||
const result = await query(
|
||||
@@ -60,8 +58,7 @@ export async function updateRole(roleId, { name, description, color, permissionK
|
||||
const values = [];
|
||||
let idx = 1;
|
||||
|
||||
// System roles cannot be renamed
|
||||
if (!isSystem && name !== undefined) {
|
||||
if (name !== undefined) {
|
||||
if (!name.trim()) throw new Error('Role name cannot be empty');
|
||||
updateFields.push(`name = $${idx++}`);
|
||||
values.push(name.trim());
|
||||
@@ -83,8 +80,9 @@ export async function updateRole(roleId, { name, description, color, permissionK
|
||||
values
|
||||
);
|
||||
|
||||
if (permissionKeys !== undefined) {
|
||||
const safeKeys = [...new Set(permissionKeys)].filter(k => VALID_PERMISSION_KEYS.has(k));
|
||||
if (!isSystem && permissionKeys !== undefined) {
|
||||
const validKeys = getRegisteredPermissionKeys();
|
||||
const safeKeys = [...new Set(permissionKeys)].filter(k => validKeys.has(k));
|
||||
await client.query(`DELETE FROM zen_auth_role_permissions WHERE role_id = $1`, [roleId]);
|
||||
for (const key of safeKeys) {
|
||||
await client.query(
|
||||
|
||||
@@ -98,4 +98,52 @@ function deleteResetToken(email) {
|
||||
return deleteWhere('zen_auth_verifications', { identifier: 'password_reset', value: email });
|
||||
}
|
||||
|
||||
export { createEmailVerification, verifyEmailToken, createPasswordReset, verifyResetToken, deleteResetToken };
|
||||
async function createAccountSetup(email) {
|
||||
const token = generateToken(32);
|
||||
const expiresAt = new Date();
|
||||
expiresAt.setHours(expiresAt.getHours() + 48);
|
||||
|
||||
await deleteWhere('zen_auth_verifications', { identifier: 'account_setup', value: email });
|
||||
|
||||
const setup = await create('zen_auth_verifications', {
|
||||
id: generateId(),
|
||||
identifier: 'account_setup',
|
||||
value: email,
|
||||
token,
|
||||
expires_at: expiresAt,
|
||||
updated_at: new Date()
|
||||
});
|
||||
|
||||
return { ...setup, token };
|
||||
}
|
||||
|
||||
async function verifyAccountSetupToken(email, token) {
|
||||
const setup = await findOne('zen_auth_verifications', {
|
||||
identifier: 'account_setup',
|
||||
value: email
|
||||
});
|
||||
|
||||
if (!setup) return false;
|
||||
|
||||
const storedBuf = Buffer.from(setup.token, 'utf8');
|
||||
const providedBuf = Buffer.from(
|
||||
token.length === setup.token.length ? token : setup.token,
|
||||
'utf8'
|
||||
);
|
||||
const tokensMatch = crypto.timingSafeEqual(storedBuf, providedBuf)
|
||||
&& token.length === setup.token.length;
|
||||
if (!tokensMatch) return false;
|
||||
|
||||
if (new Date(setup.expires_at) < new Date()) {
|
||||
await deleteWhere('zen_auth_verifications', { id: setup.id });
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function deleteAccountSetupToken(email) {
|
||||
return deleteWhere('zen_auth_verifications', { identifier: 'account_setup', value: email });
|
||||
}
|
||||
|
||||
export { createEmailVerification, verifyEmailToken, createPasswordReset, verifyResetToken, deleteResetToken, createAccountSetup, verifyAccountSetupToken, deleteAccountSetupToken };
|
||||
|
||||
@@ -3,12 +3,14 @@ import { protectAdmin } from './protect.js';
|
||||
import { buildNavigationSections, buildBottomNavItems } from './navigation.js';
|
||||
import { logoutAction } from '@zen/core/features/auth/actions';
|
||||
import { getAppName } from '@zen/core';
|
||||
import { getUserPermissions } from '@zen/core/users';
|
||||
import './widgets/index.server.js';
|
||||
|
||||
export default async function AdminLayout({ children }) {
|
||||
const session = await protectAdmin();
|
||||
const appName = getAppName();
|
||||
const navigationSections = buildNavigationSections('/');
|
||||
const permissions = await getUserPermissions(session.user.id);
|
||||
const navigationSections = buildNavigationSections('/', permissions);
|
||||
const bottomNavItems = buildBottomNavItems('/');
|
||||
|
||||
return (
|
||||
|
||||
@@ -3,18 +3,23 @@ import { protectAdmin } from './protect.js';
|
||||
import { collectWidgetData } from './registry.js';
|
||||
import { getAppConfig, getPublicBaseUrl } from '@zen/core';
|
||||
import { isDevkitEnabled } from '../../shared/lib/appConfig.js';
|
||||
import { getUserPermissions } from '@zen/core/users';
|
||||
|
||||
export default async function AdminPage({ params }) {
|
||||
const resolvedParams = await params;
|
||||
const session = await protectAdmin();
|
||||
const widgetData = await collectWidgetData();
|
||||
const [widgetData, permissions] = await Promise.all([
|
||||
collectWidgetData(),
|
||||
getUserPermissions(session.user.id),
|
||||
]);
|
||||
const appConfig = { ...getAppConfig(), siteUrl: getPublicBaseUrl() };
|
||||
const devkitEnabled = isDevkitEnabled();
|
||||
const user = { ...session.user, permissions };
|
||||
|
||||
return (
|
||||
<AdminPageClient
|
||||
params={resolvedParams}
|
||||
user={session.user}
|
||||
user={user}
|
||||
widgetData={widgetData}
|
||||
appConfig={appConfig}
|
||||
devkitEnabled={devkitEnabled}
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
# Admin
|
||||
|
||||
Ce répertoire fournit l'interface d'administration complète : layout, navigation, tableau de bord, gestion des utilisateurs et des rôles. Il expose un **registre runtime** pour permettre aux projets consommateurs d'ajouter des widgets, des entrées de navigation et des pages sans modifier le core.
|
||||
|
||||
> Le pattern `zen.extensions.js` documenté ici reste valide pour les extensions in-projet (extensions ad hoc spécifiques à une app). Pour distribuer une extension réutilisable comme un package npm, consulter [docs/MODULES.md](../../../docs/MODULES.md) — la même API d'enregistrement s'utilise mais le module est auto-découvert via les `dependencies` du projet consommateur.
|
||||
|
||||
---
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
src/features/admin/
|
||||
├── index.js protectAdmin, isAdmin, buildNavigationSections, registre
|
||||
├── protect.js gardes d'accès
|
||||
├── navigation.js buildNavigationSections, buildBottomNavItems
|
||||
├── registry.js registre runtime d'extensions
|
||||
├── AdminLayout.server.js layout RSC de l'admin
|
||||
├── AdminPage.server.js page RSC racine (protège + collecte les données widgets)
|
||||
├── AdminPage.client.js shell client
|
||||
├── components/
|
||||
│ ├── index.js re-export
|
||||
│ ├── AdminHeader.js
|
||||
│ ├── AdminShell.js
|
||||
│ ├── AdminSidebar.js
|
||||
│ ├── AdminTop.js
|
||||
│ ├── RoleEditModal.client.js
|
||||
│ ├── ThemeToggle.js
|
||||
│ ├── UserCreateModal.client.js
|
||||
│ └── UserEditModal.client.js
|
||||
├── devkit/
|
||||
│ ├── ComponentsPage.client.js
|
||||
│ ├── DevkitPage.client.js
|
||||
│ └── IconsPage.client.js
|
||||
├── pages/
|
||||
│ ├── ConfirmEmailChangePage.client.js
|
||||
│ ├── DashboardPage.client.js
|
||||
│ ├── ProfilePage.client.js
|
||||
│ ├── RolesPage.client.js
|
||||
│ ├── SettingsPage.client.js
|
||||
│ └── UsersPage.client.js
|
||||
└── widgets/
|
||||
├── index.client.js auto-registration des widgets core (côté client)
|
||||
├── index.server.js auto-registration des widgets core (côté serveur)
|
||||
├── users.client.js widget Utilisateurs (composant)
|
||||
└── users.server.js widget Utilisateurs (fetcher)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Import
|
||||
|
||||
```js
|
||||
import { protectAdmin, isAdmin, buildNavigationSections } from '@zen/core/features/admin';
|
||||
import {
|
||||
registerWidget,
|
||||
registerWidgetFetcher,
|
||||
registerNavItem,
|
||||
registerNavSection,
|
||||
registerPage,
|
||||
} from '@zen/core/features/admin';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pages intégrées
|
||||
|
||||
| Route | Page |
|
||||
|-------|------|
|
||||
| `/admin/dashboard` | Tableau de bord avec widgets |
|
||||
| `/admin/users` | Liste, création et gestion des utilisateurs |
|
||||
| `/admin/roles` | Gestion des rôles et permissions |
|
||||
| `/admin/settings` | Paramètres de l'application |
|
||||
| `/admin/profile` | Profil de l'utilisateur connecté |
|
||||
| `/admin/confirm-email-change` | Confirmation de changement d'email |
|
||||
|
||||
---
|
||||
|
||||
## API
|
||||
|
||||
### `protectAdmin(options?)`
|
||||
|
||||
Garde serveur. Redirige si l'utilisateur n'est pas connecté ou n'a pas la permission `ADMIN_ACCESS`. Retourne la session courante.
|
||||
|
||||
```js
|
||||
const session = await protectAdmin();
|
||||
// session.user est disponible
|
||||
```
|
||||
|
||||
| Option | Type | Défaut | Description |
|
||||
|--------|------|--------|-------------|
|
||||
| `redirectTo` | `string` | `'/auth/login'` | Redirection si non authentifié |
|
||||
| `forbiddenRedirect` | `string` | `'/'` | Redirection si non autorisé |
|
||||
|
||||
---
|
||||
|
||||
### `isAdmin()`
|
||||
|
||||
Vérifie si l'utilisateur courant a la permission `ADMIN_ACCESS`. Retourne `boolean`.
|
||||
|
||||
```js
|
||||
const admin = await isAdmin();
|
||||
if (!admin) return null;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `buildNavigationSections(pathname, userPermissions?)`
|
||||
|
||||
Construit les sections de navigation pour la sidebar à partir du registre. Marque l'entrée active selon `pathname`. Les items dont le champ `permission` n'est pas présent dans `userPermissions` sont automatiquement exclus ; si tous les items d'une section sont exclus, la section disparaît également.
|
||||
|
||||
```js
|
||||
const permissions = await getUserPermissions(session.user.id);
|
||||
const sections = buildNavigationSections('/admin/users', permissions);
|
||||
// [{ id, title, icon, items: [{ name, href, icon, current }] }]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Registre d'extensions
|
||||
|
||||
Le registre permet d'ajouter des widgets, des entrées de navigation et des pages sans toucher au core. Les enregistrements se font via des imports à effet de bord dans le layout racine du projet consommateur.
|
||||
|
||||
### Ajouter un widget
|
||||
|
||||
Un widget est composé de deux parties : un fetcher serveur qui collecte les données, et un composant client qui les affiche.
|
||||
|
||||
```js
|
||||
// app/admin/orders/ordersWidget.server.js
|
||||
import { registerWidgetFetcher } from '@zen/core/features/admin';
|
||||
import { countOrders } from './orders.server.js';
|
||||
|
||||
registerWidgetFetcher('orders', async () => ({
|
||||
total: await countOrders(),
|
||||
}));
|
||||
```
|
||||
|
||||
```js
|
||||
// app/admin/orders/ordersWidget.client.js
|
||||
'use client';
|
||||
import { registerWidget } from '@zen/core/features/admin';
|
||||
import { StatCard } from '@zen/core/shared/components';
|
||||
|
||||
function OrdersWidget({ data, loading }) {
|
||||
return (
|
||||
<StatCard
|
||||
title="Commandes"
|
||||
value={loading ? '-' : String(data?.total ?? 0)}
|
||||
loading={loading}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
registerWidget({ id: 'orders', Component: OrdersWidget, order: 20 });
|
||||
```
|
||||
|
||||
Le composant reçoit `data` (retour du fetcher) et `loading` (booléen). Si le fetcher échoue, `data` est `null` et `loading` reste `false`.
|
||||
|
||||
**`registerWidgetFetcher(id, fetcher)`**
|
||||
|
||||
| Paramètre | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `id` | `string` | Identifiant unique du widget |
|
||||
| `fetcher` | `async () => object` | Fonction serveur qui retourne les données |
|
||||
|
||||
**`registerWidget({ id, Component, order?, permission? })`**
|
||||
|
||||
| Paramètre | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `id` | `string` | Identifiant unique (doit correspondre au fetcher) |
|
||||
| `Component` | `ReactComponent` | Composant client affiché dans le tableau de bord |
|
||||
| `order` | `number` | Position dans la grille (défaut : `0`) |
|
||||
| `permission` | `string` | Clé de permission requise pour voir ce widget (ex. `'users.view'`). Le widget est masqué si l'utilisateur ne possède pas cette permission. |
|
||||
|
||||
---
|
||||
|
||||
### Ajouter une entrée de navigation
|
||||
|
||||
```js
|
||||
import { registerNavSection, registerNavItem } from '@zen/core/features/admin';
|
||||
|
||||
registerNavSection({ id: 'commerce', title: 'Commerce', icon: 'ShoppingBag03Icon', order: 30 });
|
||||
|
||||
registerNavItem({
|
||||
id: 'orders',
|
||||
label: 'Commandes',
|
||||
icon: 'ShoppingBag03Icon',
|
||||
href: '/admin/orders',
|
||||
sectionId: 'commerce',
|
||||
order: 10,
|
||||
permission: 'orders.view', // optionnel — masqué si l'utilisateur n'a pas cette permission
|
||||
});
|
||||
```
|
||||
|
||||
**`registerNavSection({ id, title, icon, order? })`**
|
||||
|
||||
| Paramètre | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `id` | `string` | Identifiant unique de la section |
|
||||
| `title` | `string` | Titre affiché dans la sidebar |
|
||||
| `icon` | `string` | Nom d'icône Hugeicons |
|
||||
| `order` | `number` | Ordre d'affichage (défaut : `0`) |
|
||||
|
||||
**`registerNavItem({ id, label, icon, href, sectionId?, order?, position?, permission? })`**
|
||||
|
||||
| Paramètre | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `id` | `string` | Identifiant unique de l'entrée |
|
||||
| `label` | `string` | Texte affiché |
|
||||
| `icon` | `string` | Nom d'icône Hugeicons |
|
||||
| `href` | `string` | URL de destination |
|
||||
| `sectionId` | `string` | Section parente (défaut : `'main'`) |
|
||||
| `order` | `number` | Ordre d'affichage (défaut : `0`) |
|
||||
| `position` | `string` | `'bottom'` pour épingler en bas de la sidebar |
|
||||
| `permission` | `string` | Clé de permission requise pour voir cette entrée (ex. `'orders.view'`). L'entrée est masquée si l'utilisateur ne possède pas cette permission. |
|
||||
|
||||
---
|
||||
|
||||
### Ajouter une page
|
||||
|
||||
```js
|
||||
import { registerPage } from '@zen/core/features/admin';
|
||||
import OrdersPage from './OrdersPage.js';
|
||||
|
||||
registerPage({
|
||||
slug: 'orders',
|
||||
Component: OrdersPage,
|
||||
title: 'Commandes',
|
||||
});
|
||||
```
|
||||
|
||||
La page est rendue sous `/admin/<slug>`. `AdminPage.client.js` résout le composant à partir du slug dans les paramètres de route.
|
||||
|
||||
**`registerPage({ slug, Component, title?, breadcrumbLabel? })`**
|
||||
|
||||
| Paramètre | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `slug` | `string` | Segment d'URL sous `/admin/` |
|
||||
| `Component` | `ReactComponent` | Composant client rendu pour cette route |
|
||||
| `title` | `string` | Titre de la page (optionnel) |
|
||||
| `breadcrumbLabel` | `string` | Label du fil d'Ariane (optionnel, défaut : `title`) |
|
||||
|
||||
---
|
||||
|
||||
## Câbler les extensions dans le projet consommateur
|
||||
|
||||
Regrouper tous les enregistrements dans un fichier de point d'entrée unique, puis l'importer une seule fois depuis le layout racine.
|
||||
|
||||
```js
|
||||
// app/zen.extensions.js
|
||||
import './admin/orders/ordersWidget.server.js';
|
||||
import './admin/orders/ordersWidget.client.js';
|
||||
import { registerNavSection, registerNavItem, registerPage } from '@zen/core/features/admin';
|
||||
import OrdersPage from './admin/orders/OrdersPage.js';
|
||||
|
||||
registerNavSection({ id: 'commerce', title: 'Commerce', icon: 'ShoppingBag03Icon', order: 30 });
|
||||
registerNavItem({ id: 'orders', label: 'Commandes', icon: 'ShoppingBag03Icon', href: '/admin/orders', sectionId: 'commerce', permission: 'orders.view' });
|
||||
registerPage({ slug: 'orders', Component: OrdersPage, title: 'Commandes' });
|
||||
```
|
||||
|
||||
```js
|
||||
// app/layout.js
|
||||
import './zen.extensions'; // les side effects enregistrent tout
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## DevKit
|
||||
|
||||
Le DevKit est une section de l'admin réservée au développement. Il expose une galerie de composants et un catalogue d'icônes. Il s'active via la variable d'environnement `ZEN_DEVKIT_ENABLED=true` et n'est jamais rendu en production.
|
||||
|
||||
| Route | Contenu |
|
||||
|-------|---------|
|
||||
| `/admin/devkit/components` | Galerie des composants partagés |
|
||||
| `/admin/devkit/icons` | Catalogue d'icônes Hugeicons |
|
||||
|
||||
---
|
||||
|
||||
## Ajouter un widget core
|
||||
|
||||
Les widgets intégrés au core suivent le même pattern que les widgets consommateurs, avec une étape supplémentaire : déclarer les fichiers dans les index d'auto-registration.
|
||||
|
||||
```js
|
||||
// src/features/admin/widgets/myWidget.server.js
|
||||
import { registerWidgetFetcher } from '../registry.js';
|
||||
registerWidgetFetcher('myWidget', async () => ({ ... }));
|
||||
|
||||
// src/features/admin/widgets/index.server.js
|
||||
import './myWidget.server.js'; // ajouter cette ligne
|
||||
```
|
||||
|
||||
```js
|
||||
// src/features/admin/widgets/myWidget.client.js
|
||||
'use client';
|
||||
import { registerWidget } from '../registry.js';
|
||||
// ...
|
||||
registerWidget({ id: 'myWidget', Component: MyWidget, order: 20 });
|
||||
|
||||
// src/features/admin/widgets/index.client.js
|
||||
import './myWidget.client.js'; // ajouter cette ligne
|
||||
```
|
||||
@@ -4,7 +4,7 @@ import React, { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import * as Icons from '@zen/core/shared/icons';
|
||||
import { ChevronDownIcon } from '@zen/core/shared/icons';
|
||||
import { ArrowDown01Icon } from '@zen/core/shared/icons';
|
||||
|
||||
/**
|
||||
* Resolve icon name (string) to icon component
|
||||
@@ -127,7 +127,7 @@ const AdminSidebar = ({ isMobileMenuOpen, setIsMobileMenuOpen, appName, enabledM
|
||||
<Icon className="h-[15px] w-[15px] flex-shrink-0" />
|
||||
<span>{section.title}</span>
|
||||
</div>
|
||||
<ChevronDownIcon
|
||||
<ArrowDown01Icon
|
||||
className={`h-3 w-3 transition-transform duration-[120ms] ease-out ${
|
||||
isCollapsed ? '-rotate-90' : 'rotate-0'
|
||||
}`}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { Fragment, useState, useEffect } from 'react';
|
||||
import { Menu, MenuButton, MenuItem, MenuItems, Transition } from '@headlessui/react';
|
||||
import { ChevronDownIcon, User03Icon, DashboardSquare03Icon, Logout02Icon } from '@zen/core/shared/icons';
|
||||
import { ArrowDown01Icon, ArrowRight01Icon, Menu01Icon, User03Icon, DashboardSquare03Icon, Logout02Icon } from '@zen/core/shared/icons';
|
||||
import { UserAvatar } from '@zen/core/shared/components';
|
||||
import { useRouter, usePathname } from 'next/navigation';
|
||||
import { getPage, getPages } from '../registry.js';
|
||||
@@ -47,7 +47,6 @@ const AdminTop = ({ isMobileMenuOpen, setIsMobileMenuOpen, user, onLogout, appNa
|
||||
const crumbs = [{ icon: DashboardSquare03Icon, href: '/admin/dashboard' }];
|
||||
const after = pathname.replace(/^\/admin\/?/, '');
|
||||
const segments = after.split('/').filter(Boolean);
|
||||
const [first, second] = segments;
|
||||
|
||||
if (!after || !segments.length || (segments[0] === 'dashboard' && segments.length === 1)) {
|
||||
crumbs.push({ label: pageTitle });
|
||||
@@ -55,8 +54,15 @@ const AdminTop = ({ isMobileMenuOpen, setIsMobileMenuOpen, user, onLogout, appNa
|
||||
}
|
||||
|
||||
const allItems = navigationSections.flatMap(s => s.items);
|
||||
const navItem = allItems.find(item => item.href.replace('/admin/', '').split('/')[0] === first);
|
||||
const hasSubPage = segments.length > 1;
|
||||
const navItem = allItems.find(item => {
|
||||
const itemSegs = item.href.replace('/admin/', '').split('/').filter(Boolean);
|
||||
return itemSegs.length <= segments.length && itemSegs.every((seg, i) => segments[i] === seg);
|
||||
});
|
||||
|
||||
const itemSegCount = navItem
|
||||
? navItem.href.replace('/admin/', '').split('/').filter(Boolean).length
|
||||
: 1;
|
||||
const hasSubPage = segments.length > itemSegCount;
|
||||
|
||||
if (navItem) {
|
||||
crumbs.push({ label: navItem.name, href: hasSubPage ? navItem.href : undefined });
|
||||
@@ -65,10 +71,11 @@ const AdminTop = ({ isMobileMenuOpen, setIsMobileMenuOpen, user, onLogout, appNa
|
||||
return crumbs;
|
||||
}
|
||||
|
||||
if (second === 'new') {
|
||||
const subSegment = segments[itemSegCount];
|
||||
if (subSegment === 'new') {
|
||||
crumbs.push({ label: 'Nouveau' });
|
||||
} else if (second === 'edit') {
|
||||
const page = getPages().find(p => p.slug === `${first}:edit`);
|
||||
} else if (subSegment === 'edit') {
|
||||
const page = getPages().find(p => p.slug === `${segments[0]}:edit`);
|
||||
crumbs.push({ label: page?.breadcrumbLabel || page?.title || 'Modifier' });
|
||||
}
|
||||
|
||||
@@ -87,9 +94,7 @@ const AdminTop = ({ isMobileMenuOpen, setIsMobileMenuOpen, user, onLogout, appNa
|
||||
className="p-1 rounded-md text-neutral-500 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white transition-colors duration-150"
|
||||
aria-label="Toggle menu"
|
||||
>
|
||||
<svg className={`h-5 w-5 transition-transform duration-200 ${isMobileMenuOpen ? 'rotate-90' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={isMobileMenuOpen ? "M6 18L18 6M6 6l12 12" : "M4 6h16M4 12h16M4 18h16"} />
|
||||
</svg>
|
||||
<Menu01Icon className="h-5 w-5 transition-transform duration-200" />
|
||||
</button>
|
||||
<h1 className="text-neutral-900 dark:text-white font-semibold text-sm">{appName}</h1>
|
||||
</div>
|
||||
@@ -99,9 +104,7 @@ const AdminTop = ({ isMobileMenuOpen, setIsMobileMenuOpen, user, onLogout, appNa
|
||||
{breadcrumbs.length > 0 && breadcrumbs.map((crumb, i) => (
|
||||
<Fragment key={i}>
|
||||
{i > 0 && (
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-neutral-400 dark:text-neutral-600 flex-shrink-0">
|
||||
<polyline points="9 18 15 12 9 6" />
|
||||
</svg>
|
||||
<ArrowRight01Icon className="w-3 h-3 text-neutral-400 dark:text-neutral-600 flex-shrink-0" />
|
||||
)}
|
||||
{crumb.icon ? (
|
||||
<button
|
||||
@@ -134,7 +137,7 @@ const AdminTop = ({ isMobileMenuOpen, setIsMobileMenuOpen, user, onLogout, appNa
|
||||
<span className="hidden sm:block text-[13px] leading-none font-medium text-neutral-800 dark:text-white">
|
||||
{user?.name || 'User'}
|
||||
</span>
|
||||
<ChevronDownIcon className="w-3.5 h-3.5 shrink-0 text-neutral-400 dark:text-neutral-600 transition-transform duration-200 group-data-open:rotate-180" />
|
||||
<ArrowDown01Icon className="w-3.5 h-3.5 shrink-0 text-neutral-400 dark:text-neutral-600 transition-transform duration-200 group-data-open:rotate-180" />
|
||||
</MenuButton>
|
||||
|
||||
<Transition
|
||||
|
||||
@@ -3,9 +3,6 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Input, Textarea, Switch, Modal, ColorPicker } from '@zen/core/shared/components';
|
||||
import { useToast } from '@zen/core/toast';
|
||||
import { getPermissionGroups } from '@zen/core/users/constants';
|
||||
|
||||
const PERMISSION_GROUPS = getPermissionGroups();
|
||||
|
||||
const RoleEditModal = ({ roleId, isOpen, onClose, onSaved }) => {
|
||||
const toast = useToast();
|
||||
@@ -19,9 +16,12 @@ const RoleEditModal = ({ roleId, isOpen, onClose, onSaved }) => {
|
||||
const [description, setDescription] = useState('');
|
||||
const [color, setColor] = useState('#6b7280');
|
||||
const [selectedPerms, setSelectedPerms] = useState([]);
|
||||
// Catalogue dynamique des permissions (core + modules), récupéré via l'API.
|
||||
const [permissionGroups, setPermissionGroups] = useState({});
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
fetchPermissions();
|
||||
if (isNew) {
|
||||
setName('');
|
||||
setDescription('');
|
||||
@@ -33,6 +33,18 @@ const RoleEditModal = ({ roleId, isOpen, onClose, onSaved }) => {
|
||||
fetchRole();
|
||||
}, [isOpen, roleId]);
|
||||
|
||||
const fetchPermissions = async () => {
|
||||
try {
|
||||
const response = await fetch('/zen/api/permissions', { credentials: 'include' });
|
||||
if (!response.ok) return;
|
||||
const data = await response.json();
|
||||
setPermissionGroups(data.groups || {});
|
||||
} catch {
|
||||
// Si le catalogue n'est pas joignable, on laisse l'utilisateur sauvegarder
|
||||
// ses changements ; les permissions invalides sont filtrées côté serveur.
|
||||
}
|
||||
};
|
||||
|
||||
const fetchRole = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
@@ -125,7 +137,6 @@ const RoleEditModal = ({ roleId, isOpen, onClose, onSaved }) => {
|
||||
label="Nom du rôle"
|
||||
value={name}
|
||||
onChange={setName}
|
||||
disabled={isSystem}
|
||||
placeholder="Éditeur, Modérateur..."
|
||||
required
|
||||
/>
|
||||
@@ -147,7 +158,7 @@ const RoleEditModal = ({ roleId, isOpen, onClose, onSaved }) => {
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-500 dark:text-neutral-400">Permissions</p>
|
||||
{Object.entries(PERMISSION_GROUPS).map(([group, perms]) => (
|
||||
{Object.entries(permissionGroups).map(([group, perms]) => (
|
||||
<div key={group} className="rounded-xl border border-neutral-200 dark:border-neutral-700/60 overflow-hidden">
|
||||
<div className="px-4 py-2.5 bg-neutral-50 dark:bg-neutral-800/60 border-b border-neutral-200 dark:border-neutral-700/60">
|
||||
<p className="text-[11px] font-medium font-ibm-plex-mono text-neutral-500 dark:text-neutral-400 uppercase tracking-wide">
|
||||
@@ -162,6 +173,7 @@ const RoleEditModal = ({ roleId, isOpen, onClose, onSaved }) => {
|
||||
onChange={() => togglePerm(perm.key)}
|
||||
label={perm.name}
|
||||
description={perm.description}
|
||||
disabled={isSystem}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Input, TagInput, Modal, RoleBadge } from '@zen/core/shared/components';
|
||||
import { useToast } from '@zen/core/toast';
|
||||
|
||||
const UserCreateModal = ({ isOpen, onClose, onSaved }) => {
|
||||
const toast = useToast();
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [allRoles, setAllRoles] = useState([]);
|
||||
const [selectedRoleIds, setSelectedRoleIds] = useState([]);
|
||||
const [formData, setFormData] = useState({ name: '', email: '', password: '' });
|
||||
const [errors, setErrors] = useState({});
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
setFormData({ name: '', email: '', password: '' });
|
||||
setSelectedRoleIds([]);
|
||||
setErrors({});
|
||||
setError('');
|
||||
fetchRoles();
|
||||
}, [isOpen]);
|
||||
|
||||
const fetchRoles = async () => {
|
||||
try {
|
||||
const res = await fetch('/zen/api/roles', { credentials: 'include' });
|
||||
const data = await res.json();
|
||||
setAllRoles(data.roles || []);
|
||||
} catch {
|
||||
toast.error('Impossible de charger les rôles');
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputChange = (field, value) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
if (errors[field]) setErrors(prev => ({ ...prev, [field]: null }));
|
||||
if (error) setError('');
|
||||
};
|
||||
|
||||
const validate = () => {
|
||||
const newErrors = {};
|
||||
if (!formData.name.trim()) newErrors.name = 'Le nom est requis';
|
||||
if (!formData.email.trim()) newErrors.email = 'Le courriel est requis';
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!validate()) return;
|
||||
setSaving(true);
|
||||
setError('');
|
||||
try {
|
||||
const res = await fetch('/zen/api/users', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
name: formData.name.trim(),
|
||||
email: formData.email.trim(),
|
||||
password: formData.password || undefined,
|
||||
roleIds: selectedRoleIds,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
setError(data.message || data.error || "Impossible de créer l'utilisateur");
|
||||
return;
|
||||
}
|
||||
if (data.invited) {
|
||||
toast.success('Utilisateur créé — invitation envoyée par courriel');
|
||||
} else {
|
||||
toast.success('Utilisateur créé');
|
||||
}
|
||||
onSaved?.();
|
||||
onClose();
|
||||
} catch {
|
||||
setError("Impossible de créer l'utilisateur");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const roleOptions = allRoles.map(r => ({
|
||||
value: r.id,
|
||||
label: r.name,
|
||||
color: r.color || '#6b7280',
|
||||
description: r.description || undefined,
|
||||
}));
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title="Nouvel utilisateur"
|
||||
onSubmit={handleSubmit}
|
||||
submitLabel="Créer"
|
||||
loading={saving}
|
||||
size="md"
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
{error && (
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded-lg dark:bg-red-500/10 dark:border-red-500/20">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-1.5 h-1.5 bg-red-500 rounded-full flex-shrink-0" />
|
||||
<span className="text-xs text-red-700 dark:text-red-400">{error}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Nom complet *"
|
||||
value={formData.name}
|
||||
onChange={(value) => handleInputChange('name', value)}
|
||||
placeholder="Prénom Nom"
|
||||
error={errors.name}
|
||||
/>
|
||||
<Input
|
||||
label="Courriel *"
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(value) => handleInputChange('email', value)}
|
||||
placeholder="utilisateur@exemple.com"
|
||||
error={errors.email}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<TagInput
|
||||
label="Rôles"
|
||||
options={roleOptions}
|
||||
value={selectedRoleIds}
|
||||
onChange={setSelectedRoleIds}
|
||||
placeholder="Rechercher un rôle..."
|
||||
renderTag={(opt, onRemove) => (
|
||||
<RoleBadge key={opt.value} name={opt.label} color={opt.color} onRemove={onRemove} />
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<Input
|
||||
label="Mot de passe"
|
||||
type="password"
|
||||
value={formData.password}
|
||||
onChange={(value) => handleInputChange('password', value)}
|
||||
placeholder="Laisser vide pour envoyer une invitation"
|
||||
/>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
Si vide, un courriel d'invitation sera envoyé pour que l'utilisateur crée son mot de passe.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserCreateModal;
|
||||
@@ -123,22 +123,33 @@ const UserEditModal = ({ userId, currentUserId, isOpen, onClose, onSaved }) => {
|
||||
const toAdd = selectedRoleIds.filter(id => !initialRoleIds.includes(id));
|
||||
const toRemove = initialRoleIds.filter(id => !selectedRoleIds.includes(id));
|
||||
|
||||
await Promise.all([
|
||||
...toAdd.map(roleId =>
|
||||
await Promise.all(
|
||||
toAdd.map(roleId =>
|
||||
fetch(`/zen/api/users/${userId}/roles`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ roleId }),
|
||||
})
|
||||
),
|
||||
...toRemove.map(roleId =>
|
||||
)
|
||||
);
|
||||
|
||||
const removeResults = await Promise.all(
|
||||
toRemove.map(roleId =>
|
||||
fetch(`/zen/api/users/${userId}/roles/${roleId}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
})
|
||||
),
|
||||
]);
|
||||
}).then(async res => ({ res, data: await res.json() }))
|
||||
)
|
||||
);
|
||||
|
||||
const failedRemove = removeResults.find(({ res }) => !res.ok);
|
||||
if (failedRemove) {
|
||||
toast.error(failedRemove.data?.message || failedRemove.data?.error || 'Impossible de retirer ce rôle');
|
||||
onSaved?.();
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
|
||||
if (emailChanged) {
|
||||
if (isSelf) {
|
||||
|
||||
@@ -7,3 +7,4 @@ export { default as AdminHeader } from './AdminHeader.js';
|
||||
export { default as ThemeToggle } from './ThemeToggle.js';
|
||||
export { default as UserEditModal } from './UserEditModal.client.js';
|
||||
export { default as RoleEditModal } from './RoleEditModal.client.js';
|
||||
export { default as UserCreateModal } from './UserCreateModal.client.js';
|
||||
|
||||
@@ -60,8 +60,8 @@ export default function IconsPage() {
|
||||
title={name}
|
||||
className="aspect-square flex flex-col items-center justify-center gap-2 rounded-lg border border-neutral-200 dark:border-neutral-800 bg-neutral-100 dark:bg-neutral-900 hover:border-blue-500 dark:hover:border-blue-500 transition-colors duration-100 group cursor-pointer p-2"
|
||||
>
|
||||
<IconComponent className="w-7 h-7 text-white transition-colors" />
|
||||
<span className="text-[9px] text-neutral-400 dark:text-neutral-500 leading-tight text-center break-all line-clamp-2 group-hover:text-neutral-600 dark:group-hover:text-neutral-300 w-full px-1">
|
||||
<IconComponent className="w-7 h-7 text-black dark:text-white" />
|
||||
<span className="text-[9px] text-neutral-500 dark:text-neutral-400 leading-tight text-center break-all line-clamp-2 group-hover:text-neutral-600 dark:group-hover:text-neutral-300 w-full px-1">
|
||||
{name.replace('Icon', '')}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -5,14 +5,15 @@ import {
|
||||
getNavItems,
|
||||
} from './registry.js';
|
||||
import { isDevkitEnabled } from '../../shared/lib/appConfig.js';
|
||||
import { PERMISSIONS } from '@zen/core/users';
|
||||
|
||||
// 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 });
|
||||
registerNavItem({ id: 'users', label: 'Utilisateurs', icon: 'UserMultiple02Icon', href: '/admin/users', sectionId: 'system', order: 10, permission: PERMISSIONS.USERS_VIEW });
|
||||
registerNavItem({ id: 'roles', label: 'Rôles', icon: 'Crown03Icon', href: '/admin/roles', sectionId: 'system', order: 20, permission: PERMISSIONS.ROLES_VIEW });
|
||||
registerNavItem({ id: 'settings', label: 'Paramètres', icon: 'Settings02Icon', href: '/admin/settings', position: 'bottom', order: 10 });
|
||||
|
||||
if (isDevkitEnabled()) {
|
||||
@@ -24,10 +25,17 @@ if (isDevkitEnabled()) {
|
||||
/**
|
||||
* Build sections for AdminSidebar. Items are sérialisables (pas de composants),
|
||||
* icônes en chaînes résolues côté client.
|
||||
* @param {string} pathname
|
||||
* @param {string[]} [userPermissions] - Permissions de l'utilisateur connecté ; les items
|
||||
* avec un champ `permission` sont masqués si la permission n'est pas présente.
|
||||
*/
|
||||
export function buildNavigationSections(pathname) {
|
||||
export function buildNavigationSections(pathname, userPermissions = []) {
|
||||
const sections = getNavSections();
|
||||
const items = getNavItems().filter(item => item.position !== 'bottom');
|
||||
const items = getNavItems().filter(item => {
|
||||
if (item.position === 'bottom') return false;
|
||||
if (item.permission && !userPermissions.includes(item.permission)) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const bySection = new Map();
|
||||
for (const item of items) {
|
||||
|
||||
@@ -3,9 +3,10 @@
|
||||
import { getWidgets, registerPage } from '../registry.js';
|
||||
import AdminHeader from '../components/AdminHeader.js';
|
||||
|
||||
export default function DashboardPage({ stats }) {
|
||||
export default function DashboardPage({ user, stats }) {
|
||||
const loading = stats === null || stats === undefined;
|
||||
const widgets = getWidgets();
|
||||
const permissions = user?.permissions ?? [];
|
||||
const widgets = getWidgets().filter(w => !w.permission || permissions.includes(w.permission));
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 sm:gap-6 lg:gap-8">
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useToast } from '@zen/core/toast';
|
||||
import AdminHeader from '../components/AdminHeader.js';
|
||||
import RoleEditModal from '../components/RoleEditModal.client.js';
|
||||
|
||||
const RolesPageClient = () => {
|
||||
const RolesPageClient = ({ canManage }) => {
|
||||
const toast = useToast();
|
||||
const [roles, setRoles] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -73,7 +73,7 @@ const RolesPageClient = () => {
|
||||
render: (role) => role.is_system ? <Badge variant="default" size="sm">système</Badge> : null,
|
||||
skeleton: { height: 'h-4', width: '60px' },
|
||||
},
|
||||
{
|
||||
...(canManage ? [{
|
||||
key: 'actions',
|
||||
label: '',
|
||||
sortable: false,
|
||||
@@ -99,7 +99,7 @@ const RolesPageClient = () => {
|
||||
</div>
|
||||
),
|
||||
skeleton: { height: 'h-8', width: '80px', className: 'rounded-lg' },
|
||||
},
|
||||
}] : []),
|
||||
];
|
||||
|
||||
const fetchRoles = async () => {
|
||||
@@ -161,14 +161,17 @@ const RolesPageClient = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const RolesPage = () => (
|
||||
<div className="flex flex-col gap-4 sm:gap-6 lg:gap-8">
|
||||
<RolesPageHeader />
|
||||
<RolesPageClient />
|
||||
</div>
|
||||
);
|
||||
const RolesPage = ({ user }) => {
|
||||
const canManage = user?.permissions?.includes('roles.manage');
|
||||
return (
|
||||
<div className="flex flex-col gap-4 sm:gap-6 lg:gap-8">
|
||||
<RolesPageHeader canManage={canManage} />
|
||||
<RolesPageClient canManage={canManage} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const RolesPageHeader = () => {
|
||||
const RolesPageHeader = ({ canManage }) => {
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
|
||||
return (
|
||||
@@ -176,17 +179,19 @@ const RolesPageHeader = () => {
|
||||
<AdminHeader
|
||||
title="Rôles"
|
||||
description="Gérez les rôles et leurs permissions"
|
||||
action={
|
||||
action={canManage && (
|
||||
<Button variant="primary" onClick={() => setModalOpen(true)}>
|
||||
Nouveau rôle
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<RoleEditModal
|
||||
roleId="new"
|
||||
isOpen={modalOpen}
|
||||
onClose={() => setModalOpen(false)}
|
||||
)}
|
||||
/>
|
||||
{canManage && (
|
||||
<RoleEditModal
|
||||
roleId="new"
|
||||
isOpen={modalOpen}
|
||||
onClose={() => setModalOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -7,8 +7,9 @@ import { PencilEdit01Icon } from '@zen/core/shared/icons';
|
||||
import { useToast } from '@zen/core/toast';
|
||||
import AdminHeader from '../components/AdminHeader.js';
|
||||
import UserEditModal from '../components/UserEditModal.client.js';
|
||||
import UserCreateModal from '../components/UserCreateModal.client.js';
|
||||
|
||||
const UsersPageClient = ({ currentUserId }) => {
|
||||
const UsersPageClient = ({ currentUserId, refreshKey, canEdit }) => {
|
||||
const toast = useToast();
|
||||
const [users, setUsers] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -77,7 +78,7 @@ const UsersPageClient = ({ currentUserId }) => {
|
||||
render: (user) => <RelativeDate date={user.created_at} />,
|
||||
skeleton: { height: 'h-4', width: '70%' },
|
||||
},
|
||||
{
|
||||
...(canEdit ? [{
|
||||
key: 'actions',
|
||||
label: '',
|
||||
sortable: false,
|
||||
@@ -93,7 +94,7 @@ const UsersPageClient = ({ currentUserId }) => {
|
||||
</Button>
|
||||
),
|
||||
skeleton: { height: 'h-8', width: '80px', className: 'rounded-lg' },
|
||||
},
|
||||
}] : []),
|
||||
];
|
||||
|
||||
const fetchUsers = async () => {
|
||||
@@ -126,7 +127,7 @@ const UsersPageClient = ({ currentUserId }) => {
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers();
|
||||
}, [sortBy, sortOrder, pagination.page, pagination.limit]);
|
||||
}, [sortBy, sortOrder, pagination.page, pagination.limit, refreshKey]);
|
||||
|
||||
const handlePageChange = (newPage) => setPagination(prev => ({ ...prev, page: newPage }));
|
||||
const handleLimitChange = (newLimit) => setPagination(prev => ({ ...prev, limit: newLimit, page: 1 }));
|
||||
@@ -157,23 +158,46 @@ const UsersPageClient = ({ currentUserId }) => {
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<UserEditModal
|
||||
userId={editingUserId}
|
||||
currentUserId={currentUserId}
|
||||
isOpen={!!editingUserId}
|
||||
onClose={() => setEditingUserId(null)}
|
||||
onSaved={fetchUsers}
|
||||
/>
|
||||
{canEdit && (
|
||||
<UserEditModal
|
||||
userId={editingUserId}
|
||||
currentUserId={currentUserId}
|
||||
isOpen={!!editingUserId}
|
||||
onClose={() => setEditingUserId(null)}
|
||||
onSaved={fetchUsers}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const UsersPage = ({ user }) => (
|
||||
<div className="flex flex-col gap-4 sm:gap-6 lg:gap-8">
|
||||
<AdminHeader title="Utilisateurs" description="Gérez les comptes utilisateurs" />
|
||||
<UsersPageClient currentUserId={user?.id} />
|
||||
</div>
|
||||
);
|
||||
const UsersPage = ({ user }) => {
|
||||
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
const canEdit = user?.permissions?.includes('users.manage');
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 sm:gap-6 lg:gap-8">
|
||||
<AdminHeader
|
||||
title="Utilisateurs"
|
||||
description="Gérez les comptes utilisateurs"
|
||||
action={canEdit && (
|
||||
<Button variant="primary" onClick={() => setCreateModalOpen(true)}>
|
||||
Nouvel utilisateur
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
<UsersPageClient currentUserId={user?.id} refreshKey={refreshKey} canEdit={canEdit} />
|
||||
{canEdit && (
|
||||
<UserCreateModal
|
||||
isOpen={createModalOpen}
|
||||
onClose={() => setCreateModalOpen(false)}
|
||||
onSaved={() => setRefreshKey(k => k + 1)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UsersPage;
|
||||
|
||||
|
||||
@@ -25,8 +25,8 @@ export function registerWidgetFetcher(id, fetcher) {
|
||||
widgetFetchers.set(id, fetcher);
|
||||
}
|
||||
|
||||
export function registerWidget({ id, Component, order = 0 }) {
|
||||
widgetComponents.set(id, { Component, order });
|
||||
export function registerWidget({ id, Component, order = 0, permission }) {
|
||||
widgetComponents.set(id, { Component, order, permission });
|
||||
}
|
||||
|
||||
export function getWidgets() {
|
||||
@@ -57,8 +57,8 @@ 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', position }) {
|
||||
navItems.set(id, { id, label, icon, href, order, sectionId, position });
|
||||
export function registerNavItem({ id, label, icon, href, order = 0, sectionId = 'main', position, permission }) {
|
||||
navItems.set(id, { id, label, icon, href, order, sectionId, position, permission });
|
||||
}
|
||||
|
||||
export function getNavSections() {
|
||||
|
||||
@@ -20,4 +20,4 @@ function UsersWidget({ data, loading }) {
|
||||
);
|
||||
}
|
||||
|
||||
registerWidget({ id: 'users', Component: UsersWidget, order: 10 });
|
||||
registerWidget({ id: 'users', Component: UsersWidget, order: 10, permission: 'users.view' });
|
||||
|
||||
@@ -8,6 +8,7 @@ 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';
|
||||
import SetupAccountPage from './pages/SetupAccountPage.client.js';
|
||||
|
||||
const PAGE_COMPONENTS = {
|
||||
login: LoginPage,
|
||||
@@ -16,6 +17,7 @@ const PAGE_COMPONENTS = {
|
||||
reset: ResetPasswordPage,
|
||||
confirm: ConfirmEmailPage,
|
||||
logout: LogoutPage,
|
||||
setup: SetupAccountPage,
|
||||
};
|
||||
|
||||
export default function AuthPage({
|
||||
@@ -26,6 +28,7 @@ export default function AuthPage({
|
||||
forgotPasswordAction,
|
||||
resetPasswordAction,
|
||||
verifyEmailAction,
|
||||
setupAccountAction,
|
||||
logoutAction,
|
||||
setSessionCookieAction,
|
||||
redirectAfterLogin = '/',
|
||||
@@ -81,6 +84,8 @@ export default function AuthPage({
|
||||
return <Page {...common} onSubmit={resetPasswordAction} email={email} token={token} />;
|
||||
case ConfirmEmailPage:
|
||||
return <Page {...common} onSubmit={verifyEmailAction} email={email} token={token} />;
|
||||
case SetupAccountPage:
|
||||
return <Page {...common} onSubmit={setupAccountAction} email={email} token={token} />;
|
||||
case LogoutPage:
|
||||
return <Page onLogout={logoutAction} onSetSessionCookie={setSessionCookieAction} />;
|
||||
default:
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
forgotPasswordAction,
|
||||
resetPasswordAction,
|
||||
verifyEmailAction,
|
||||
setupAccountAction,
|
||||
setSessionCookie,
|
||||
getSession,
|
||||
} from './actions.js';
|
||||
@@ -14,7 +15,7 @@ export default async function AuthPage({ params, searchParams }) {
|
||||
const session = await getSession();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-4 md:p-8 bg-neutral-50 dark:bg-black">
|
||||
<div className="min-h-screen flex flex-col items-center justify-start sm:justify-center px-4 py-10 sm:py-8 md:p-8 bg-neutral-50 dark:bg-black">
|
||||
<div className="max-w-md w-full">
|
||||
<AuthPageClient
|
||||
params={params}
|
||||
@@ -25,6 +26,7 @@ export default async function AuthPage({ params, searchParams }) {
|
||||
forgotPasswordAction={forgotPasswordAction}
|
||||
resetPasswordAction={resetPasswordAction}
|
||||
verifyEmailAction={verifyEmailAction}
|
||||
setupAccountAction={setupAccountAction}
|
||||
setSessionCookieAction={setSessionCookie}
|
||||
redirectAfterLogin={process.env.ZEN_AUTH_REDIRECT_AFTER_LOGIN || '/'}
|
||||
currentUser={session?.user || null}
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
# Auth
|
||||
|
||||
Ce répertoire gère l'authentification : inscription, connexion, sessions, réinitialisation de mot de passe, vérification d'adresse courriel et gestion du profil. Il expose des server actions Next.js, des routes API REST et des composants de pages prêts à l'emploi.
|
||||
|
||||
---
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
src/features/auth/
|
||||
├── index.js barrel serveur
|
||||
├── actions.js server actions Next.js ('use server')
|
||||
├── api.js routes API REST (users, roles)
|
||||
├── auth.js register, login, resetPassword, updateUser, completeAccountSetup
|
||||
├── session.js createSession, validateSession, deleteSession
|
||||
├── email.js tokens de vérification + envoi des e-mails
|
||||
├── password.js hashPassword, verifyPassword, generateToken
|
||||
├── db.js createTables, dropTables
|
||||
├── storage-policies.js politiques d'accès au stockage
|
||||
├── AuthPage.server.js page RSC racine (route catch-all)
|
||||
├── AuthPage.client.js shell client
|
||||
├── GUIDE-custom-login.md guide pour les pages personnalisées
|
||||
├── components/
|
||||
│ └── AuthPageHeader.js
|
||||
├── pages/
|
||||
│ ├── index.js re-export
|
||||
│ ├── LoginPage.client.js
|
||||
│ ├── RegisterPage.client.js
|
||||
│ ├── ForgotPasswordPage.client.js
|
||||
│ ├── ResetPasswordPage.client.js
|
||||
│ ├── ConfirmEmailPage.client.js
|
||||
│ ├── SetupAccountPage.client.js
|
||||
│ └── LogoutPage.client.js
|
||||
└── templates/
|
||||
├── VerificationEmail.js
|
||||
├── PasswordResetEmail.js
|
||||
├── PasswordChangedEmail.js
|
||||
├── EmailChangeConfirmEmail.js
|
||||
├── EmailChangeNotifyEmail.js
|
||||
└── InvitationEmail.js
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Import
|
||||
|
||||
```js
|
||||
import { getSession, loginAction, logoutAction } from '@zen/core/features/auth/actions';
|
||||
import { LoginPage, RegisterPage } from '@zen/core/features/auth/pages';
|
||||
import { validateSession, createSession } from '@zen/core/features/auth';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pages intégrées
|
||||
|
||||
La route catch-all `app/auth/[...auth]/page.js` suffit pour exposer toutes les pages sans configuration supplémentaire.
|
||||
|
||||
```js
|
||||
// app/auth/[...auth]/page.js
|
||||
export { default } from '@zen/core/features/auth/server';
|
||||
```
|
||||
|
||||
| Route | Page |
|
||||
|-------|------|
|
||||
| `/auth/login` | Connexion |
|
||||
| `/auth/register` | Inscription |
|
||||
| `/auth/forgot` | Mot de passe oublié |
|
||||
| `/auth/reset` | Réinitialisation du mot de passe |
|
||||
| `/auth/confirm` | Vérification de l'adresse courriel |
|
||||
| `/auth/setup` | Configuration du compte après invitation admin |
|
||||
| `/auth/logout` | Déconnexion |
|
||||
|
||||
---
|
||||
|
||||
## Server actions
|
||||
|
||||
Toutes les actions sont dans `@zen/core/features/auth/actions`. Elles attendent un `FormData` sauf `getSession`, `setSessionCookie` et `refreshSessionCookie`.
|
||||
|
||||
### `getSession()`
|
||||
|
||||
Lit le cookie de session et retourne la session courante, ou `null` si l'utilisateur n'est pas connecté. Renouvelle automatiquement le cookie si la session a été rafraîchie.
|
||||
|
||||
```js
|
||||
const session = await getSession();
|
||||
if (!session?.user) redirect('/auth/login');
|
||||
// session.user, session.session disponibles
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `loginAction(formData)`
|
||||
|
||||
Authentifie l'utilisateur et pose un cookie `HttpOnly`. Applique le rate limiting par IP et les vérifications anti-bot.
|
||||
|
||||
```js
|
||||
const result = await loginAction(formData);
|
||||
// { success: true, user } ou { success: false, error }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `registerAction(formData)`
|
||||
|
||||
Crée un compte et envoie l'e-mail de vérification.
|
||||
|
||||
| Champ | Description |
|
||||
|-------|-------------|
|
||||
| `email` | Adresse courriel |
|
||||
| `password` | Mot de passe |
|
||||
| `name` | Nom d'affichage |
|
||||
|
||||
---
|
||||
|
||||
### `logoutAction()`
|
||||
|
||||
Invalide la session en base et supprime le cookie.
|
||||
|
||||
---
|
||||
|
||||
### `forgotPasswordAction(formData)`
|
||||
|
||||
Envoie un lien de réinitialisation si un compte existe pour l'adresse fournie. La réponse ne révèle pas si le compte existe.
|
||||
|
||||
---
|
||||
|
||||
### `resetPasswordAction(formData)`
|
||||
|
||||
Vérifie le token puis met à jour le mot de passe.
|
||||
|
||||
| Champ | Description |
|
||||
|-------|-------------|
|
||||
| `email` | Adresse courriel |
|
||||
| `token` | Token reçu par e-mail |
|
||||
| `newPassword` | Nouveau mot de passe |
|
||||
|
||||
---
|
||||
|
||||
### `verifyEmailAction(formData)`
|
||||
|
||||
Vérifie le token de confirmation et marque l'adresse comme vérifiée.
|
||||
|
||||
| Champ | Description |
|
||||
|-------|-------------|
|
||||
| `email` | Adresse courriel |
|
||||
| `token` | Token reçu par e-mail |
|
||||
|
||||
---
|
||||
|
||||
### `setupAccountAction(formData)`
|
||||
|
||||
Vérifie le token d'invitation, crée le compte credential et marque l'adresse comme vérifiée. Appelée depuis `/auth/setup` après qu'un admin a créé le compte sans mot de passe.
|
||||
|
||||
| Champ | Description |
|
||||
|-------|-------------|
|
||||
| `email` | Adresse courriel |
|
||||
| `token` | Token reçu dans le courriel d'invitation |
|
||||
| `newPassword` | Mot de passe choisi |
|
||||
| `confirmPassword` | Confirmation du mot de passe |
|
||||
|
||||
---
|
||||
|
||||
### `setSessionCookie(token)`
|
||||
|
||||
Valide le token contre la base avant de l'écrire dans le cookie `HttpOnly`. À utiliser après une authentification externe ou OAuth.
|
||||
|
||||
---
|
||||
|
||||
### `refreshSessionCookie(token)`
|
||||
|
||||
Revalide le token et prolonge la durée de vie du cookie (30 jours).
|
||||
|
||||
---
|
||||
|
||||
## Routes API REST
|
||||
|
||||
Les routes sont enregistrées automatiquement sous le préfixe `/zen/api`. L'authentification est appliquée par le routeur avant chaque handler.
|
||||
|
||||
### Utilisateurs
|
||||
|
||||
| Méthode | Route | Auth | Description |
|
||||
|---------|-------|------|-------------|
|
||||
| `GET` | `/zen/api/users` | admin | Liste paginée des utilisateurs |
|
||||
| `POST` | `/zen/api/users` | admin | Créer un utilisateur (avec ou sans invitation) |
|
||||
| `GET` | `/zen/api/users/:id` | admin | Détail d'un utilisateur |
|
||||
| `PUT` | `/zen/api/users/:id` | admin | Modifier `name`, `role`, `email_verified` |
|
||||
| `PUT` | `/zen/api/users/:id/email` | admin | Changer l'adresse courriel |
|
||||
| `PUT` | `/zen/api/users/:id/password` | admin | Définir un mot de passe |
|
||||
| `POST` | `/zen/api/users/:id/send-password-reset` | admin | Envoyer un lien de réinitialisation |
|
||||
| `GET` | `/zen/api/users/:id/roles` | admin | Lister les rôles de l'utilisateur |
|
||||
| `POST` | `/zen/api/users/:id/roles` | admin | Assigner un rôle |
|
||||
| `DELETE` | `/zen/api/users/:id/roles/:roleId` | admin | Révoquer un rôle |
|
||||
|
||||
### Profil (utilisateur connecté)
|
||||
|
||||
| Méthode | Route | Description |
|
||||
|---------|-------|-------------|
|
||||
| `PUT` | `/zen/api/users/profile` | Modifier le nom |
|
||||
| `POST` | `/zen/api/users/profile/email` | Initier un changement d'adresse courriel |
|
||||
| `GET` | `/zen/api/users/email/confirm` | Confirmer le changement d'adresse |
|
||||
| `POST` | `/zen/api/users/profile/password` | Changer le mot de passe |
|
||||
| `POST` | `/zen/api/users/profile/picture` | Téléverser une photo de profil |
|
||||
| `DELETE` | `/zen/api/users/profile/picture` | Supprimer la photo de profil |
|
||||
| `GET` | `/zen/api/users/profile/sessions` | Lister les sessions actives |
|
||||
| `DELETE` | `/zen/api/users/profile/sessions` | Révoquer toutes les sessions |
|
||||
| `DELETE` | `/zen/api/users/profile/sessions/:sessionId` | Révoquer une session |
|
||||
|
||||
### Rôles
|
||||
|
||||
| Méthode | Route | Description |
|
||||
|---------|-------|-------------|
|
||||
| `GET` | `/zen/api/roles` | Lister les rôles |
|
||||
| `POST` | `/zen/api/roles` | Créer un rôle |
|
||||
| `GET` | `/zen/api/roles/:id` | Détail d'un rôle |
|
||||
| `PUT` | `/zen/api/roles/:id` | Modifier un rôle |
|
||||
| `DELETE` | `/zen/api/roles/:id` | Supprimer un rôle |
|
||||
|
||||
---
|
||||
|
||||
## Invitation par l'admin
|
||||
|
||||
Un administrateur peut créer un utilisateur depuis `/admin/users → Nouvel utilisateur`. Deux flux selon si un mot de passe est fourni :
|
||||
|
||||
**Avec mot de passe :** l'utilisateur est créé avec `email_verified = true` et un compte credential. Il peut se connecter immédiatement.
|
||||
|
||||
**Sans mot de passe :** l'utilisateur est créé avec `email_verified = false` et aucun compte credential. Un token `account_setup` (48 h) est généré et un courriel d'invitation est envoyé. L'utilisateur clique sur le lien `/auth/setup?email=X&token=Y`, choisit son mot de passe, et le compte est activé (`email_verified = true`) en une seule étape — le passage par le lien vaut confirmation du courriel.
|
||||
|
||||
```
|
||||
Admin crée l'utilisateur (sans mdp)
|
||||
→ POST /zen/api/users
|
||||
→ zen_auth_users créé (email_verified: false)
|
||||
→ token account_setup enregistré dans zen_auth_verifications (48 h)
|
||||
→ courriel InvitationEmail envoyé
|
||||
|
||||
Utilisateur clique sur le lien /auth/setup
|
||||
→ SetupAccountPage (setupAccountAction)
|
||||
→ token vérifié
|
||||
→ zen_auth_accounts créé avec mot de passe haché
|
||||
→ email_verified = true
|
||||
→ token supprimé
|
||||
→ redirection vers /auth/login
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Sécurité
|
||||
|
||||
**Rate limiting par IP.** Les actions `register`, `login`, `forgot_password`, `reset_password` et `verify_email` sont limitées par adresse IP. Quand l'IP est inconnue (pas de proxy configuré), le rate limiting est suspendu et un avertissement opérateur est émis une seule fois. Activer avec `ZEN_TRUST_PROXY=true` derrière un reverse proxy vérifié.
|
||||
|
||||
**Champs anti-bot.** Chaque formulaire embarque un champ honeypot (`_hp`) et un timestamp de chargement (`_t`). Une soumission trop rapide (moins de 1,5 s), trop ancienne (plus de 10 min) ou avec un honeypot rempli est rejetée.
|
||||
|
||||
**Cookie HttpOnly.** Le token de session n'est jamais exposé à JavaScript. `setSessionCookie` et `refreshSessionCookie` valident le token en base avant d'écrire le cookie pour éviter qu'un token arbitraire soit accepté.
|
||||
|
||||
**Erreurs opaques.** Les erreurs internes sont loguées côté serveur et remplacées par un message générique côté client. Seules les `UserFacingError` (token expiré, etc.) remontent verbatim.
|
||||
|
||||
---
|
||||
|
||||
## Base de données
|
||||
|
||||
`db.js` expose `createTables()` et `dropTables()`, appelés par `initializeZen()`.
|
||||
|
||||
| Table | Contenu |
|
||||
|-------|---------|
|
||||
| `zen_auth_users` | Utilisateurs (`id`, `email`, `name`, `role`, `email_verified`, `image`) |
|
||||
| `zen_auth_sessions` | Sessions actives avec IP et user-agent |
|
||||
| `zen_auth_accounts` | Comptes liés à un provider (credential, OAuth) |
|
||||
| `zen_auth_verifications` | Tokens de vérification d'e-mail et de réinitialisation |
|
||||
|
||||
---
|
||||
|
||||
## Pages personnalisées
|
||||
|
||||
Pour envelopper les pages auth dans un layout existant, voir [GUIDE-custom-login.md](./GUIDE-custom-login.md). Le guide couvre le pattern serveur/client, les props de chaque composant et la protection de route.
|
||||
@@ -1,6 +1,6 @@
|
||||
'use server';
|
||||
|
||||
import { register, login, requestPasswordReset, resetPassword, verifyUserEmail } from './auth.js';
|
||||
import { register, login, requestPasswordReset, resetPassword, verifyUserEmail, completeAccountSetup } from './auth.js';
|
||||
import { validateSession, deleteSession } from './session.js';
|
||||
import { verifyEmailToken, verifyResetToken, sendVerificationEmail, sendPasswordResetEmail } from './email.js';
|
||||
import { fail } from '@zen/core/shared/logger';
|
||||
@@ -121,7 +121,8 @@ export async function loginAction(formData) {
|
||||
const botCheck = validateAntiBotFields(formData);
|
||||
if (!botCheck.valid) return { success: false, error: botCheck.error };
|
||||
|
||||
const ip = await getClientIp();
|
||||
const h = await headers();
|
||||
const ip = getIpFromHeaders(h);
|
||||
const rl = enforceRateLimit(ip, 'login');
|
||||
if (rl && !rl.allowed) {
|
||||
return { success: false, error: `Trop de tentatives. Réessayez dans ${formatRetryAfter(rl.retryAfterMs)}.` };
|
||||
@@ -129,8 +130,8 @@ export async function loginAction(formData) {
|
||||
|
||||
const email = formData.get('email');
|
||||
const password = formData.get('password');
|
||||
|
||||
const result = await login({ email, password });
|
||||
const userAgent = h.get('user-agent') || null;
|
||||
const result = await login({ email, password }, { ipAddress: ip !== 'unknown' ? ip : null, userAgent });
|
||||
|
||||
// An HttpOnly cookie is the only safe transport for session tokens; setting it
|
||||
// here keeps the token out of any JavaScript-readable response payload.
|
||||
@@ -322,6 +323,42 @@ export async function resetPasswordAction(formData) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function setupAccountAction(formData) {
|
||||
try {
|
||||
const ip = await getClientIp();
|
||||
const rl = enforceRateLimit(ip, 'setup_account');
|
||||
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');
|
||||
const confirmPassword = formData.get('confirmPassword');
|
||||
|
||||
if (!newPassword || !confirmPassword) {
|
||||
throw new UserFacingError('Les deux champs de mot de passe sont requis');
|
||||
}
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
throw new UserFacingError('Les mots de passe ne correspondent pas');
|
||||
}
|
||||
|
||||
await completeAccountSetup({ email, token, password: newPassword });
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Mot de passe créé avec succès. Vous pouvez maintenant vous connecter.'
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof UserFacingError) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
fail(`Auth: setupAccountAction 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();
|
||||
|
||||
+135
-19
@@ -7,11 +7,12 @@
|
||||
* the context argument: (request, params, { session }).
|
||||
*/
|
||||
|
||||
import { query, updateById, findOne } from '@zen/core/database';
|
||||
import { query, create, updateById, findOne } from '@zen/core/database';
|
||||
import { updateUser, requestPasswordReset } from './auth.js';
|
||||
import { hashPassword, verifyPassword } from '../../core/users/password.js';
|
||||
import { createEmailChangeToken, verifyEmailChangeToken, applyEmailChange, sendEmailChangeConfirmEmail, sendEmailChangeOldNotifyEmail, sendEmailChangeNewNotifyEmail, sendPasswordChangedEmail, sendPasswordResetEmail } from './email.js';
|
||||
import { listRoles, getRoleById, createRole, updateRole, deleteRole, getUserRoles, assignUserRole, revokeUserRole, deleteUserSessions } from '@zen/core/users';
|
||||
import { hashPassword, verifyPassword, generateId } from '../../core/users/password.js';
|
||||
import { createEmailChangeToken, verifyEmailChangeToken, applyEmailChange, sendEmailChangeConfirmEmail, sendEmailChangeOldNotifyEmail, sendEmailChangeNewNotifyEmail, sendPasswordChangedEmail, sendPasswordResetEmail, sendInvitationEmail } from './email.js';
|
||||
import { createAccountSetup } from '../../core/users/verifications.js';
|
||||
import { listRoles, getRoleById, createRole, updateRole, deleteRole, getUserRoles, assignUserRole, revokeUserRole, deleteUserSessions, PERMISSIONS, getRegisteredPermissions } from '@zen/core/users';
|
||||
import { uploadImage, deleteFile, generateUniqueFilename, getFileExtension, FILE_TYPE_PRESETS, FILE_SIZE_LIMITS, validateUpload } from '@zen/core/storage';
|
||||
import { getPublicBaseUrl } from '@zen/core/shared/config';
|
||||
|
||||
@@ -525,8 +526,26 @@ async function handleAssignUserRole(request, { id: userId }) {
|
||||
// DELETE /zen/api/users/:id/roles/:roleId (admin only)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function handleRevokeUserRole(_request, { id: userId, roleId }) {
|
||||
async function handleRevokeUserRole(_request, { id: userId, roleId }, context) {
|
||||
try {
|
||||
if (context.session.user.id === userId) {
|
||||
const roleHasPerm = await query(
|
||||
`SELECT 1 FROM zen_auth_role_permissions WHERE role_id = $1 AND permission_key = $2`,
|
||||
[roleId, PERMISSIONS.USERS_MANAGE]
|
||||
);
|
||||
if (roleHasPerm.rows.length > 0) {
|
||||
const otherRoles = await query(
|
||||
`SELECT 1 FROM zen_auth_user_roles ur
|
||||
JOIN zen_auth_role_permissions rp ON rp.role_id = ur.role_id
|
||||
WHERE ur.user_id = $1 AND rp.permission_key = $2 AND ur.role_id != $3
|
||||
LIMIT 1`,
|
||||
[userId, PERMISSIONS.USERS_MANAGE, roleId]
|
||||
);
|
||||
if (otherRoles.rows.length === 0) {
|
||||
return apiError('Forbidden', "Vous ne pouvez pas retirer ce rôle car c'est votre seule source de la permission de gestion des utilisateurs.");
|
||||
}
|
||||
}
|
||||
}
|
||||
await revokeUserRole(userId, roleId);
|
||||
return apiSuccess({ success: true });
|
||||
} catch (error) {
|
||||
@@ -544,6 +563,21 @@ async function handleListRoles() {
|
||||
return apiSuccess({ roles });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /zen/api/permissions (admin only)
|
||||
// Catalogue dynamique : core + permissions enregistrées par les modules.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function handleListPermissions() {
|
||||
const permissions = getRegisteredPermissions();
|
||||
const groups = permissions.reduce((acc, perm) => {
|
||||
if (!acc[perm.group_name]) acc[perm.group_name] = [];
|
||||
acc[perm.group_name].push(perm);
|
||||
return acc;
|
||||
}, {});
|
||||
return apiSuccess({ permissions, groups });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /zen/api/roles (admin only)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -807,6 +841,86 @@ async function handleAdminSendPasswordReset(_request, { id: userId }) {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /zen/api/users (admin only)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function handleAdminCreateUser(request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { name, email, password, roleIds } = body;
|
||||
|
||||
if (!name || typeof name !== 'string' || name.trim().length === 0) {
|
||||
return apiError('Bad Request', 'Le nom est requis');
|
||||
}
|
||||
|
||||
if (name.trim().length > 100) {
|
||||
return apiError('Bad Request', 'Le nom doit contenir 100 caractères ou moins');
|
||||
}
|
||||
|
||||
if (!email || !EMAIL_REGEX.test(email) || email.length > 254) {
|
||||
return apiError('Bad Request', 'Adresse courriel invalide');
|
||||
}
|
||||
|
||||
const normalizedEmail = email.trim().toLowerCase();
|
||||
|
||||
const existing = await findOne('zen_auth_users', { email: normalizedEmail });
|
||||
if (existing) {
|
||||
return apiError('Conflict', 'Cette adresse courriel est déjà utilisée');
|
||||
}
|
||||
|
||||
const userId = generateId();
|
||||
const hasPassword = typeof password === 'string' && password.length > 0;
|
||||
|
||||
const user = await create('zen_auth_users', {
|
||||
id: userId,
|
||||
email: normalizedEmail,
|
||||
name: name.trim(),
|
||||
email_verified: hasPassword,
|
||||
image: null,
|
||||
role: 'user',
|
||||
updated_at: new Date()
|
||||
});
|
||||
|
||||
if (hasPassword) {
|
||||
const hashedPassword = await hashPassword(password);
|
||||
await create('zen_auth_accounts', {
|
||||
id: generateId(),
|
||||
account_id: normalizedEmail,
|
||||
provider_id: 'credential',
|
||||
user_id: user.id,
|
||||
password: hashedPassword,
|
||||
updated_at: new Date()
|
||||
});
|
||||
} else {
|
||||
const setup = await createAccountSetup(normalizedEmail);
|
||||
const baseUrl = getPublicBaseUrl();
|
||||
try {
|
||||
await sendInvitationEmail(normalizedEmail, setup.token, baseUrl);
|
||||
} catch (emailError) {
|
||||
fail(`handleAdminCreateUser: failed to send invitation email to ${normalizedEmail}: ${emailError.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(roleIds) && roleIds.length > 0) {
|
||||
for (const roleId of roleIds) {
|
||||
if (typeof roleId === 'string' && roleId.length > 0) {
|
||||
try {
|
||||
await assignUserRole(user.id, roleId);
|
||||
} catch (err) {
|
||||
fail(`handleAdminCreateUser: failed to assign role ${roleId} to user ${user.id}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return apiSuccess({ user, invited: !hasPassword });
|
||||
} catch (error) {
|
||||
logAndObscureError(error, null);
|
||||
return apiError('Internal Server Error', 'Impossible de créer l\'utilisateur');
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Route definitions
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -815,7 +929,8 @@ async function handleAdminSendPasswordReset(_request, { id: userId }) {
|
||||
// parameterised paths (/users/:id) so they match first.
|
||||
|
||||
export const routes = defineApiRoutes([
|
||||
{ path: '/users', method: 'GET', handler: handleListUsers, auth: 'admin' },
|
||||
{ path: '/users', method: 'GET', handler: handleListUsers, auth: 'admin', permission: PERMISSIONS.USERS_VIEW },
|
||||
{ path: '/users', method: 'POST', handler: handleAdminCreateUser, auth: 'admin', permission: PERMISSIONS.USERS_MANAGE },
|
||||
{ path: '/users/profile', method: 'PUT', handler: handleUpdateProfile, auth: 'user' },
|
||||
{ path: '/users/profile/email', method: 'POST', handler: handleInitiateEmailChange, auth: 'user' },
|
||||
{ path: '/users/profile/password', method: 'POST', handler: handleChangeOwnPassword, auth: 'user' },
|
||||
@@ -825,17 +940,18 @@ export const routes = defineApiRoutes([
|
||||
{ path: '/users/profile/sessions', method: 'DELETE', handler: handleDeleteAllSessions, auth: 'user' },
|
||||
{ path: '/users/profile/sessions/:sessionId', method: 'DELETE', handler: handleDeleteSession, auth: 'user' },
|
||||
{ path: '/users/email/confirm', method: 'GET', handler: handleConfirmEmailChange, auth: 'user' },
|
||||
{ path: '/users/:id/roles', method: 'GET', handler: handleGetUserRoles, auth: 'admin' },
|
||||
{ path: '/users/:id/roles', method: 'POST', handler: handleAssignUserRole, auth: 'admin' },
|
||||
{ path: '/users/:id/roles/:roleId', method: 'DELETE', handler: handleRevokeUserRole, auth: 'admin' },
|
||||
{ path: '/users/:id', method: 'GET', handler: handleGetUserById, auth: 'admin' },
|
||||
{ path: '/users/:id', method: 'PUT', handler: handleUpdateUserById, auth: 'admin' },
|
||||
{ path: '/users/:id/email', method: 'PUT', handler: handleAdminUpdateUserEmail, auth: 'admin' },
|
||||
{ path: '/users/:id/password', method: 'PUT', handler: handleAdminSetUserPassword, auth: 'admin' },
|
||||
{ path: '/users/:id/send-password-reset', method: 'POST', handler: handleAdminSendPasswordReset, auth: 'admin' },
|
||||
{ path: '/roles', method: 'GET', handler: handleListRoles, auth: 'admin' },
|
||||
{ path: '/roles', method: 'POST', handler: handleCreateRole, auth: 'admin' },
|
||||
{ path: '/roles/:id', method: 'GET', handler: handleGetRole, auth: 'admin' },
|
||||
{ path: '/roles/:id', method: 'PUT', handler: handleUpdateRole, auth: 'admin' },
|
||||
{ path: '/roles/:id', method: 'DELETE', handler: handleDeleteRole, auth: 'admin' },
|
||||
{ path: '/users/:id/roles', method: 'GET', handler: handleGetUserRoles, auth: 'admin', permission: PERMISSIONS.USERS_VIEW },
|
||||
{ path: '/users/:id/roles', method: 'POST', handler: handleAssignUserRole, auth: 'admin', permission: PERMISSIONS.USERS_MANAGE },
|
||||
{ path: '/users/:id/roles/:roleId', method: 'DELETE', handler: handleRevokeUserRole, auth: 'admin', permission: PERMISSIONS.USERS_MANAGE },
|
||||
{ path: '/users/:id', method: 'GET', handler: handleGetUserById, auth: 'admin', permission: PERMISSIONS.USERS_VIEW },
|
||||
{ path: '/users/:id', method: 'PUT', handler: handleUpdateUserById, auth: 'admin', permission: PERMISSIONS.USERS_MANAGE },
|
||||
{ path: '/users/:id/email', method: 'PUT', handler: handleAdminUpdateUserEmail, auth: 'admin', permission: PERMISSIONS.USERS_MANAGE },
|
||||
{ path: '/users/:id/password', method: 'PUT', handler: handleAdminSetUserPassword, auth: 'admin', permission: PERMISSIONS.USERS_MANAGE },
|
||||
{ path: '/users/:id/send-password-reset', method: 'POST', handler: handleAdminSendPasswordReset, auth: 'admin', permission: PERMISSIONS.USERS_MANAGE },
|
||||
{ path: '/roles', method: 'GET', handler: handleListRoles, auth: 'admin', permission: PERMISSIONS.ROLES_VIEW },
|
||||
{ path: '/permissions', method: 'GET', handler: handleListPermissions, auth: 'admin', permission: PERMISSIONS.ROLES_VIEW },
|
||||
{ path: '/roles', method: 'POST', handler: handleCreateRole, auth: 'admin', permission: PERMISSIONS.ROLES_MANAGE },
|
||||
{ path: '/roles/:id', method: 'GET', handler: handleGetRole, auth: 'admin', permission: PERMISSIONS.ROLES_VIEW },
|
||||
{ path: '/roles/:id', method: 'PUT', handler: handleUpdateRole, auth: 'admin', permission: PERMISSIONS.ROLES_MANAGE },
|
||||
{ path: '/roles/:id', method: 'DELETE', handler: handleDeleteRole, auth: 'admin', permission: PERMISSIONS.ROLES_MANAGE },
|
||||
]);
|
||||
|
||||
@@ -4,7 +4,8 @@ import {
|
||||
login,
|
||||
requestPasswordReset,
|
||||
verifyUserEmail,
|
||||
updateUser
|
||||
updateUser,
|
||||
completeAccountSetup
|
||||
} from '../../core/users/auth.js';
|
||||
import { sendPasswordChangedEmail } from './email.js';
|
||||
|
||||
@@ -19,4 +20,4 @@ export function resetPassword(resetData) {
|
||||
return _resetPassword(resetData, { onPasswordChanged: sendPasswordChangedEmail });
|
||||
}
|
||||
|
||||
export { login, requestPasswordReset, verifyUserEmail, updateUser };
|
||||
export { login, requestPasswordReset, verifyUserEmail, updateUser, completeAccountSetup };
|
||||
|
||||
@@ -6,6 +6,7 @@ import { PasswordResetEmail } from './templates/PasswordResetEmail.js';
|
||||
import { PasswordChangedEmail } from './templates/PasswordChangedEmail.js';
|
||||
import { EmailChangeConfirmEmail } from './templates/EmailChangeConfirmEmail.js';
|
||||
import { EmailChangeNotifyEmail } from './templates/EmailChangeNotifyEmail.js';
|
||||
import { InvitationEmail } from './templates/InvitationEmail.js';
|
||||
|
||||
export { createEmailVerification, verifyEmailToken, createPasswordReset, verifyResetToken, deleteResetToken }
|
||||
from '../../core/users/verifications.js';
|
||||
@@ -93,4 +94,17 @@ async function sendEmailChangeNewNotifyEmail(newEmail, oldEmail) {
|
||||
return result;
|
||||
}
|
||||
|
||||
export { sendVerificationEmail, sendPasswordResetEmail, sendPasswordChangedEmail, sendEmailChangeConfirmEmail, sendEmailChangeOldNotifyEmail, sendEmailChangeNewNotifyEmail };
|
||||
async function sendInvitationEmail(email, token, baseUrl) {
|
||||
const appName = process.env.ZEN_NAME || 'ZEN';
|
||||
const setupUrl = `${baseUrl}/auth/setup?email=${encodeURIComponent(email)}&token=${token}`;
|
||||
const html = await render(<InvitationEmail setupUrl={setupUrl} companyName={appName} />);
|
||||
const result = await sendEmail({ to: email, subject: `Terminez la création de votre compte – ${appName}`, html });
|
||||
if (!result.success) {
|
||||
fail(`Auth: failed to send invitation email to ${email}: ${result.error}`);
|
||||
throw new Error('Failed to send invitation email');
|
||||
}
|
||||
info(`Auth: invitation email sent to ${email}`);
|
||||
return result;
|
||||
}
|
||||
|
||||
export { sendVerificationEmail, sendPasswordResetEmail, sendPasswordChangedEmail, sendEmailChangeConfirmEmail, sendEmailChangeOldNotifyEmail, sendEmailChangeNewNotifyEmail, sendInvitationEmail };
|
||||
|
||||
@@ -9,7 +9,8 @@ export {
|
||||
requestPasswordReset,
|
||||
resetPassword,
|
||||
verifyUserEmail,
|
||||
updateUser
|
||||
updateUser,
|
||||
completeAccountSetup
|
||||
} from './auth.js';
|
||||
|
||||
export {
|
||||
@@ -28,7 +29,8 @@ export {
|
||||
deleteResetToken,
|
||||
sendVerificationEmail,
|
||||
sendPasswordResetEmail,
|
||||
sendPasswordChangedEmail
|
||||
sendPasswordChangedEmail,
|
||||
sendInvitationEmail
|
||||
} from './email.js';
|
||||
|
||||
export {
|
||||
@@ -46,6 +48,7 @@ export {
|
||||
forgotPasswordAction,
|
||||
resetPasswordAction,
|
||||
verifyEmailAction,
|
||||
setupAccountAction,
|
||||
setSessionCookie,
|
||||
refreshSessionCookie
|
||||
} from './actions.js';
|
||||
|
||||
@@ -80,7 +80,7 @@ export default function ConfirmEmailPage({ onSubmit, onNavigate, email, token })
|
||||
console.log('ConfirmEmailPage render', { success, error, isLoading, hasVerified });
|
||||
|
||||
return (
|
||||
<Card variant="default" padding="md" spacing="none" className="w-full max-w-md">
|
||||
<Card variant="default" padding="md" spacing="none" className="w-full max-w-md mx-auto">
|
||||
<AuthPageHeader title="Vérification de l'e-mail" description="Nous vérifions votre adresse e-mail..." />
|
||||
|
||||
{isLoading && (
|
||||
|
||||
@@ -45,7 +45,7 @@ export default function ForgotPasswordPage({ onSubmit, onNavigate, currentUser =
|
||||
}
|
||||
|
||||
return (
|
||||
<Card variant="default" padding="md" spacing="none" className="w-full max-w-md">
|
||||
<Card variant="default" padding="md" spacing="none" className="w-full max-w-md mx-auto">
|
||||
<AuthPageHeader title="Mot de passe oublié" description="Entrez votre adresse e-mail et nous vous enverrons un lien pour réinitialiser votre mot de passe." />
|
||||
|
||||
{currentUser && (
|
||||
|
||||
@@ -67,7 +67,7 @@ export default function LoginPage({ onSubmit, onNavigate, onSetSessionCookie, re
|
||||
};
|
||||
|
||||
return (
|
||||
<Card variant="default" padding="md" spacing="none" className="w-full max-w-md">
|
||||
<Card variant="default" padding="md" spacing="none" className="w-full max-w-md mx-auto">
|
||||
<AuthPageHeader title="Connexion" description="Veuillez vous connecter pour continuer." />
|
||||
|
||||
{currentUser && (
|
||||
|
||||
@@ -42,7 +42,7 @@ export default function LogoutPage({ onLogout, onSetSessionCookie }) {
|
||||
};
|
||||
|
||||
return (
|
||||
<Card variant="default" padding="md" spacing="none" className="w-full max-w-md">
|
||||
<Card variant="default" padding="md" spacing="none" className="w-full max-w-md mx-auto">
|
||||
<AuthPageHeader title="Prêt à vous déconnecter ?" description="Cela mettra fin à votre session et vous déconnectera de votre compte." />
|
||||
|
||||
{success && (
|
||||
|
||||
@@ -97,7 +97,7 @@ export default function RegisterPage({ onSubmit, onNavigate, currentUser = null
|
||||
}
|
||||
|
||||
return (
|
||||
<Card variant="default" padding="md" spacing="none" className="w-full max-w-md">
|
||||
<Card variant="default" padding="md" spacing="none" className="w-full max-w-md mx-auto">
|
||||
<AuthPageHeader title="Créer un compte" description="Inscrivez-vous pour commencer." />
|
||||
|
||||
{currentUser && (
|
||||
|
||||
@@ -65,7 +65,7 @@ export default function ResetPasswordPage({ onSubmit, onNavigate, email, token }
|
||||
}
|
||||
|
||||
return (
|
||||
<Card variant="default" padding="md" spacing="none" className="w-full max-w-md">
|
||||
<Card variant="default" padding="md" spacing="none" className="w-full max-w-md mx-auto">
|
||||
<AuthPageHeader title="Réinitialiser le mot de passe" description="Saisissez votre nouveau mot de passe ci-dessous." />
|
||||
|
||||
{error && !success && (
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Card, Input, Button, PasswordStrengthIndicator } from '@zen/core/shared/components';
|
||||
import AuthPageHeader from '../components/AuthPageHeader.js';
|
||||
|
||||
export default function SetupAccountPage({ onSubmit, onNavigate, email, token }) {
|
||||
const [error, setError] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [success, setSuccess] = useState('');
|
||||
const [formData, setFormData] = useState({ newPassword: '', confirmPassword: '' });
|
||||
|
||||
const validatePassword = (password) => {
|
||||
const errors = [];
|
||||
if (password.length < 8) errors.push('Le mot de passe doit contenir au moins 8 caractères');
|
||||
if (password.length > 128) errors.push('Le mot de passe doit contenir 128 caractères ou moins');
|
||||
if (!/[A-Z]/.test(password)) errors.push('Le mot de passe doit contenir au moins une majuscule');
|
||||
if (!/[a-z]/.test(password)) errors.push('Le mot de passe doit contenir au moins une minuscule');
|
||||
if (!/\d/.test(password)) errors.push('Le mot de passe doit contenir au moins un chiffre');
|
||||
return errors;
|
||||
};
|
||||
|
||||
const isFormValid = () => {
|
||||
return validatePassword(formData.newPassword).length === 0 &&
|
||||
formData.newPassword === formData.confirmPassword &&
|
||||
formData.newPassword.length > 0;
|
||||
};
|
||||
|
||||
async function handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setSuccess('');
|
||||
setIsLoading(true);
|
||||
|
||||
const passwordErrors = validatePassword(formData.newPassword);
|
||||
if (passwordErrors.length > 0) { setError(passwordErrors[0]); setIsLoading(false); return; }
|
||||
if (formData.newPassword !== formData.confirmPassword) {
|
||||
setError('Les mots de passe ne correspondent pas');
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const submitData = new FormData();
|
||||
submitData.append('newPassword', formData.newPassword);
|
||||
submitData.append('confirmPassword', formData.confirmPassword);
|
||||
submitData.append('email', email);
|
||||
submitData.append('token', token);
|
||||
|
||||
try {
|
||||
const result = await onSubmit(submitData);
|
||||
|
||||
if (result.success) {
|
||||
setSuccess(result.message);
|
||||
setIsLoading(false);
|
||||
setTimeout(() => onNavigate('login'), 2000);
|
||||
} else {
|
||||
setError(result.error || 'Impossible de créer le mot de passe');
|
||||
setIsLoading(false);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Setup account error:', err);
|
||||
setError('Une erreur inattendue s\'est produite');
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card variant="default" padding="md" spacing="none" className="w-full max-w-md mx-auto">
|
||||
<AuthPageHeader
|
||||
title="Créez votre mot de passe"
|
||||
description="Un administrateur a créé votre compte. Choisissez un mot de passe pour y accéder."
|
||||
/>
|
||||
|
||||
{error && !success && (
|
||||
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg dark:bg-red-500/10 dark:border-red-500/20">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-1.5 h-1.5 bg-red-500 rounded-full flex-shrink-0"></div>
|
||||
<span className="text-xs text-red-700 dark:text-red-400">{error}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<div className="mb-4 p-3 bg-green-50 border border-green-200 rounded-lg dark:bg-green-500/10 dark:border-green-500/20">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-1.5 h-1.5 bg-green-500 rounded-full flex-shrink-0"></div>
|
||||
<span className="text-xs text-green-700 dark:text-green-400">{success}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||
<div>
|
||||
<Input
|
||||
id="newPassword"
|
||||
name="newPassword"
|
||||
type="password"
|
||||
label="Mot de passe"
|
||||
value={formData.newPassword}
|
||||
onChange={(value) => setFormData(prev => ({ ...prev, newPassword: value }))}
|
||||
placeholder="••••••••"
|
||||
disabled={!!success}
|
||||
minLength="8"
|
||||
maxLength="128"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
/>
|
||||
<PasswordStrengthIndicator password={formData.newPassword} showRequirements={true} />
|
||||
</div>
|
||||
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
name="confirmPassword"
|
||||
type="password"
|
||||
label="Confirmer le mot de passe"
|
||||
value={formData.confirmPassword}
|
||||
onChange={(value) => setFormData(prev => ({ ...prev, confirmPassword: value }))}
|
||||
placeholder="••••••••"
|
||||
disabled={!!success}
|
||||
minLength="8"
|
||||
maxLength="128"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
size="lg"
|
||||
loading={isLoading}
|
||||
disabled={!!success || !isFormValid()}
|
||||
className="w-full mt-2"
|
||||
>
|
||||
Créer mon mot de passe
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 flex justify-center">
|
||||
<Button
|
||||
type="button"
|
||||
variant="fullghost"
|
||||
onClick={() => onNavigate('login')}
|
||||
>
|
||||
← Retour à la connexion
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -4,3 +4,4 @@ export { default as ForgotPasswordPage } from './ForgotPasswordPage.client.js';
|
||||
export { default as ResetPasswordPage } from './ResetPasswordPage.client.js';
|
||||
export { default as ConfirmEmailPage } from './ConfirmEmailPage.client.js';
|
||||
export { default as LogoutPage } from './LogoutPage.client.js';
|
||||
export { default as SetupAccountPage } from './SetupAccountPage.client.js';
|
||||
|
||||
@@ -32,7 +32,7 @@ export const EmailChangeConfirmEmail = ({ confirmUrl, newEmail, companyName }) =
|
||||
</Section>
|
||||
|
||||
<Text className="text-[12px] leading-[20px] text-neutral-400 m-0">
|
||||
Ce lien expire dans 24 heures. Si vous n'êtes pas à l'origine de cette demande, ignorez ce message — votre adresse actuelle restera inchangée.
|
||||
Ce lien expire dans 24 heures. Si vous n'êtes pas à l'origine de cette demande, ignorez ce message. Votre adresse actuelle reste inchangée.
|
||||
</Text>
|
||||
|
||||
<Text className="text-[12px] leading-[20px] text-neutral-400 m-0 mt-[8px]">
|
||||
|
||||
@@ -6,19 +6,19 @@ const VARIANTS = {
|
||||
preview: (name) => `Demande de modification de courriel – ${name}`,
|
||||
title: 'Demande de modification de courriel',
|
||||
body: (name) => `Une demande de modification de l'adresse courriel associée à votre compte ${name} a été initiée.`,
|
||||
note: "Si vous n'êtes pas à l'origine de cette demande, contactez immédiatement notre équipe de support. Votre adresse actuelle reste active jusqu'à confirmation.",
|
||||
note: "Si vous n'êtes pas à l'origine de cette demande, contactez le support immédiatement. Votre adresse actuelle reste active jusqu'à confirmation.",
|
||||
},
|
||||
changed: {
|
||||
preview: (name) => `Votre adresse courriel a été modifiée – ${name}`,
|
||||
title: 'Adresse courriel modifiée',
|
||||
body: (name) => `L'adresse courriel de votre compte ${name} a été modifiée par un administrateur.`,
|
||||
note: "Si vous n'êtes pas à l'origine de cette modification, contactez immédiatement notre équipe de support.",
|
||||
note: "Si vous n'êtes pas à l'origine de cette modification, contactez le support immédiatement.",
|
||||
},
|
||||
admin_new: {
|
||||
preview: (name) => `Votre compte est maintenant associé à cette adresse – ${name}`,
|
||||
title: 'Adresse courriel associée à votre compte',
|
||||
body: (name) => `Votre adresse courriel est maintenant associée à un compte ${name}. Cette modification a été effectuée par un administrateur.`,
|
||||
note: "Si vous n'avez pas été informé de cette modification, contactez notre équipe de support.",
|
||||
note: "Si vous n'avez pas été informé de cette modification, contactez le support.",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Button, Section, Text, Link } from "@react-email/components";
|
||||
import { BaseLayout } from "@zen/core/email/templates";
|
||||
|
||||
export const InvitationEmail = ({ setupUrl, companyName }) => (
|
||||
<BaseLayout
|
||||
preview={`Terminez la création de votre compte ${companyName}`}
|
||||
title="Créez votre mot de passe"
|
||||
companyName={companyName}
|
||||
supportSection={true}
|
||||
>
|
||||
<Text className="text-[14px] leading-[24px] text-neutral-600 mt-[4px] mb-[24px]">
|
||||
Un administrateur a créé un compte pour vous sur <span className="font-medium text-neutral-900">{companyName}</span>. Cliquez sur le bouton ci-dessous pour choisir votre mot de passe et accéder à votre compte.
|
||||
</Text>
|
||||
|
||||
<Section className="mt-[28px] mb-[32px]">
|
||||
<Button
|
||||
href={setupUrl}
|
||||
className="bg-neutral-900 rounded-[8px] text-white font-medium px-[20px] py-[11px] no-underline text-[13px]"
|
||||
>
|
||||
Créer mon mot de passe
|
||||
</Button>
|
||||
</Section>
|
||||
|
||||
<Text className="text-[12px] leading-[20px] text-neutral-400 m-0">
|
||||
Ce lien expire dans 48 heures. Si vous n'attendiez pas cette invitation, ignorez ce message.
|
||||
</Text>
|
||||
|
||||
<Text className="text-[12px] leading-[20px] text-neutral-400 m-0 mt-[8px]">
|
||||
Lien :{' '}
|
||||
<Link href={setupUrl} className="text-neutral-400 underline break-all">
|
||||
{setupUrl}
|
||||
</Link>
|
||||
</Text>
|
||||
</BaseLayout>
|
||||
);
|
||||
@@ -9,7 +9,7 @@ export const PasswordChangedEmail = ({ email, companyName }) => (
|
||||
supportSection={true}
|
||||
>
|
||||
<Text className="text-[14px] leading-[24px] text-neutral-600 mt-[4px] mb-[24px]">
|
||||
Ceci confirme que le mot de passe de votre compte <span className="font-medium text-neutral-900">{companyName}</span> a bien été modifié.
|
||||
Le mot de passe associé au compte <span className="font-medium text-neutral-900">{companyName}</span> a été modifié.
|
||||
</Text>
|
||||
|
||||
<Section style={{ border: '1px solid #E5E5E5' }} className="bg-neutral-100 rounded-[12px] p-[20px] my-[24px]">
|
||||
@@ -22,7 +22,7 @@ export const PasswordChangedEmail = ({ email, companyName }) => (
|
||||
</Section>
|
||||
|
||||
<Text className="text-[12px] leading-[20px] text-neutral-400 m-0">
|
||||
Si vous n'êtes pas à l'origine de cette modification, contactez immédiatement notre équipe de support.
|
||||
Si vous n'êtes pas à l'origine de cette modification, contactez le support immédiatement.
|
||||
</Text>
|
||||
</BaseLayout>
|
||||
);
|
||||
|
||||
@@ -9,7 +9,7 @@ export const PasswordResetEmail = ({ resetUrl, companyName }) => (
|
||||
supportSection={true}
|
||||
>
|
||||
<Text className="text-[14px] leading-[24px] text-neutral-600 mt-[4px] mb-[24px]">
|
||||
Nous avons reçu une demande de réinitialisation du mot de passe pour votre compte <span className="font-medium text-neutral-900">{companyName}</span>. Cliquez sur le bouton ci-dessous pour en choisir un nouveau.
|
||||
Une demande de réinitialisation du mot de passe a été reçue pour le compte <span className="font-medium text-neutral-900">{companyName}</span>. Cliquez sur le bouton pour en choisir un nouveau.
|
||||
</Text>
|
||||
|
||||
<Section className="mt-[28px] mb-[32px]">
|
||||
@@ -22,7 +22,7 @@ export const PasswordResetEmail = ({ resetUrl, companyName }) => (
|
||||
</Section>
|
||||
|
||||
<Text className="text-[12px] leading-[20px] text-neutral-400 m-0">
|
||||
Ce lien expire dans 1 heure. Si vous n'êtes pas à l'origine de cette demande, ignorez ce message — votre mot de passe ne sera pas modifié.
|
||||
Ce lien expire dans 1 heure. Si vous n'êtes pas à l'origine de cette demande, ignorez ce message. Votre mot de passe ne sera pas modifié.
|
||||
</Text>
|
||||
|
||||
<Text className="text-[12px] leading-[20px] text-neutral-400 m-0 mt-[8px]">
|
||||
|
||||
@@ -9,7 +9,7 @@ export const VerificationEmail = ({ verificationUrl, companyName }) => (
|
||||
supportSection={true}
|
||||
>
|
||||
<Text className="text-[14px] leading-[24px] text-neutral-600 mt-[4px] mb-[24px]">
|
||||
Merci de vous être inscrit sur <span className="font-medium text-neutral-900">{companyName}</span>. Cliquez sur le bouton ci-dessous pour confirmer votre adresse courriel.
|
||||
Confirmez votre adresse courriel pour accéder à votre compte <span className="font-medium text-neutral-900">{companyName}</span>.
|
||||
</Text>
|
||||
|
||||
<Section className="mt-[28px] mb-[32px]">
|
||||
|
||||
+59
-7
@@ -1,25 +1,66 @@
|
||||
/**
|
||||
* Core Feature Database Initialization (CLI)
|
||||
* Database initialization for features and modules.
|
||||
*
|
||||
* 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().
|
||||
* - Features core : auth (et tout futur core ayant un db.js).
|
||||
* - Modules externes : découverts via discoverModules() ; chaque module
|
||||
* exporte ses propres createTables/dropTables.
|
||||
*
|
||||
* Les permissions ajoutées par les modules doivent être enregistrées AVANT
|
||||
* le seed de la BD pour qu'elles soient persistées et auto-attribuées au
|
||||
* rôle admin. C'est pour cela qu'on appelle register() de chaque module
|
||||
* avant initFeatures().
|
||||
*/
|
||||
|
||||
import { createTables as authCreate, dropTables as authDrop } from './auth/db.js';
|
||||
import { done, fail, info, step } from '@zen/core/shared/logger';
|
||||
import { discoverModules, validateModuleEnvVars } from '../core/modules/discover.server.js';
|
||||
import { getRegisteredModules } from '../core/modules/registry.js';
|
||||
import { registerPermissions } from '../core/users/permissions-registry.js';
|
||||
|
||||
const FEATURES = [
|
||||
const CORE_FEATURES = [
|
||||
{ name: 'auth', createTables: authCreate, dropTables: authDrop },
|
||||
];
|
||||
|
||||
async function loadModules() {
|
||||
await discoverModules();
|
||||
const modules = getRegisteredModules();
|
||||
validateModuleEnvVars(modules);
|
||||
|
||||
// Enregistre les permissions du module et exécute son register() pour que
|
||||
// tous les hooks runtime soient en place avant le seed.
|
||||
for (const mod of modules) {
|
||||
if (Array.isArray(mod.manifest?.permissions)) {
|
||||
registerPermissions(mod.manifest.permissions);
|
||||
}
|
||||
if (typeof mod.register === 'function') {
|
||||
try {
|
||||
await mod.register();
|
||||
} catch (error) {
|
||||
fail(`zen-modules: ${mod.manifest.name} register() threw — ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return modules;
|
||||
}
|
||||
|
||||
export async function initFeatures() {
|
||||
const created = [];
|
||||
const skipped = [];
|
||||
|
||||
step('Initializing feature databases...');
|
||||
|
||||
for (const { name, createTables } of FEATURES) {
|
||||
// Charger les modules d'abord pour que leurs permissions soient connues
|
||||
// au moment du seed (et donc auto-attribuées au rôle admin).
|
||||
const modules = await loadModules();
|
||||
|
||||
const targets = [
|
||||
...CORE_FEATURES,
|
||||
...modules
|
||||
.filter(m => typeof m.createTables === 'function')
|
||||
.map(m => ({ name: m.manifest.name, createTables: m.createTables, dropTables: m.dropTables })),
|
||||
];
|
||||
|
||||
for (const { name, createTables } of targets) {
|
||||
try {
|
||||
step(`Initializing ${name}...`);
|
||||
if (typeof createTables !== 'function') {
|
||||
@@ -40,7 +81,18 @@ export async function initFeatures() {
|
||||
}
|
||||
|
||||
export async function dropFeatures() {
|
||||
for (const { name, dropTables } of [...FEATURES].reverse()) {
|
||||
const modules = await loadModules();
|
||||
|
||||
// Ordre de création : core, puis modules. Drop = ordre inverse pour que
|
||||
// les tables modules (qui peuvent avoir des FK vers core) tombent d'abord.
|
||||
const targets = [
|
||||
...CORE_FEATURES,
|
||||
...modules
|
||||
.filter(m => typeof m.dropTables === 'function')
|
||||
.map(m => ({ name: m.manifest.name, dropTables: m.dropTables })),
|
||||
];
|
||||
|
||||
for (const { name, dropTables } of [...targets].reverse()) {
|
||||
try {
|
||||
if (typeof dropTables !== 'function') {
|
||||
info(`${name} has no dropTables function`);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { Tick02Icon } from '@zen/core/shared/icons';
|
||||
|
||||
const ROW1 = ['#4489ed', '#2a9db0', '#43b53c', '#5e7b4e', '#f5c211', '#f7581f', '#ff2b2b', '#ff2e63', '#f540ed', '#b34ce9', '#818faf', '#c0bfbc'];
|
||||
const ROW2 = ['#2657cf', '#24687a', '#287124', '#384d2f', '#c68408', '#c0280e', '#ca0505', '#ce0245', '#b417a7', '#8021aa', '#4e5b7e', '#75746f'];
|
||||
@@ -9,11 +10,7 @@ const PRESET_COLORS = [...ROW1, ...ROW2, ...ROW3];
|
||||
|
||||
const isValidHex = (hex) => /^#[0-9a-fA-F]{6}$/.test(hex);
|
||||
|
||||
const Checkmark = () => (
|
||||
<svg className="w-4 h-4 text-white drop-shadow-sm" fill="none" viewBox="0 0 24 24" strokeWidth={3} stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="m4.5 12.75 6 6 9-13.5" />
|
||||
</svg>
|
||||
);
|
||||
const Checkmark = () => <Tick02Icon className="w-4 h-4 text-white drop-shadow-sm" />;
|
||||
|
||||
const ColorPicker = ({
|
||||
value,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import React from 'react';
|
||||
import Badge from './Badge';
|
||||
import Button from './Button';
|
||||
import { TorriGateIcon } from '../icons/index.js';
|
||||
import { TorriGateIcon, ArrowDown01Icon } from '@zen/core/shared/icons';
|
||||
|
||||
const ROW_SIZE = {
|
||||
sm: { cell: 'px-4 py-[11px]', header: 'px-4 py-[9px]', mobile: 'p-4' },
|
||||
@@ -41,13 +41,7 @@ const Table = ({
|
||||
const isDesc = isActive && sortOrder === 'desc';
|
||||
return (
|
||||
<span className="ml-1">
|
||||
<svg
|
||||
className={`w-4 h-4 transition-transform ${isActive ? (isDesc ? 'rotate-180' : '') : 'text-neutral-500 dark:text-neutral-400'}`}
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path fillRule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<ArrowDown01Icon className={`w-4 h-4 transition-transform ${isActive ? (isDesc ? 'rotate-180' : '') : 'text-neutral-500 dark:text-neutral-400'}`} />
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
+33
-13
@@ -1,21 +1,33 @@
|
||||
|
||||
export const ChevronDownIcon = (props) => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width={16} height={16} color={"currentColor"} fill={"none"} {...props}>
|
||||
<path d="M18 9.00005C18 9.00005 13.5811 15 12 15C10.4188 15 6 9 6 9" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"></path>
|
||||
</svg>
|
||||
export const ArrowDown01Icon = (props) => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width={64} height={64} color={"currentColor"} fill={"none"} {...props}>
|
||||
<path d="M5.99977 9.00005L11.9998 15L17.9998 9" stroke="currentColor" strokeWidth="2" strokeMiterlimit="16" strokeLinecap="round" strokeLinejoin="round"></path>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const ChevronRightIcon = (props) => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width={16} height={16} color={"currentColor"} fill={"none"} {...props}>
|
||||
<path d="M9 6C9 6 15 10.4189 15 12C15 13.5812 9 18 9 18" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"></path>
|
||||
</svg>
|
||||
export const ArrowLeft01Icon = (props) => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width={64} height={64} color={"currentColor"} fill={"none"} {...props}>
|
||||
<path d="M15 6L9 12.0001L15 18" stroke="currentColor" strokeWidth="2" strokeMiterlimit="16" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const ArrowRight01Icon = (props) => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width={64} height={64} color={"currentColor"} fill={"none"} {...props}>
|
||||
<path d="M9.00005 6L15 12L9 18" stroke="currentColor" strokeWidth="2" strokeMiterlimit="16" strokeLinecap="round" strokeLinejoin="round"></path>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const ArrowUp01Icon = (props) => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width={64} height={64} color={"currentColor"} fill={"none"} {...props}>
|
||||
<path d="M18 15L12 9L6 15" stroke="currentColor" strokeWidth="2" strokeMiterlimit="16" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
|
||||
export const UserCircle02Icon = (props) => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width={64} height={64} color={"currentColor"} fill={"none"} {...props}>
|
||||
<path fillRule="evenodd" clipRule="evenodd" d="M12 3.20455C7.1424 3.20455 3.20455 7.1424 3.20455 12C3.20455 16.8576 7.1424 20.7955 12 20.7955C16.8576 20.7955 20.7955 16.8576 20.7955 12C20.7955 7.1424 16.8576 3.20455 12 3.20455ZM1.25 12C1.25 6.06294 6.06294 1.25 12 1.25C17.9371 1.25 22.75 6.06294 22.75 12C22.75 17.9371 17.9371 22.75 12 22.75C6.06294 22.75 1.25 17.9371 1.25 12Z" fill="#ffffff"></path>
|
||||
<path d="M8.5 9.5C8.5 7.567 10.067 6 12 6C13.933 6 15.5 7.567 15.5 9.5C15.5 11.433 13.933 13 12 13C10.067 13 8.5 11.433 8.5 9.5Z" fill="#ffffff"></path>
|
||||
<path d="M5.40873 17.6472C6.43247 15.8556 8.3377 14.75 10.4011 14.75H13.5979C15.6613 14.75 17.5666 15.8556 18.5903 17.6472L19.6094 19.5928C17.6634 21.5432 14.9724 22.7499 11.9996 22.7499C9.0267 22.7499 6.33569 21.5431 4.38965 19.5928L5.40873 17.6472Z" fill="#ffffff"></path>
|
||||
<path fillRule="evenodd" clipRule="evenodd" d="M12 3.20455C7.1424 3.20455 3.20455 7.1424 3.20455 12C3.20455 16.8576 7.1424 20.7955 12 20.7955C16.8576 20.7955 20.7955 16.8576 20.7955 12C20.7955 7.1424 16.8576 3.20455 12 3.20455ZM1.25 12C1.25 6.06294 6.06294 1.25 12 1.25C17.9371 1.25 22.75 6.06294 22.75 12C22.75 17.9371 17.9371 22.75 12 22.75C6.06294 22.75 1.25 17.9371 1.25 12Z" fill="currentColor"></path>
|
||||
<path d="M8.5 9.5C8.5 7.567 10.067 6 12 6C13.933 6 15.5 7.567 15.5 9.5C15.5 11.433 13.933 13 12 13C10.067 13 8.5 11.433 8.5 9.5Z" fill="currentColor"></path>
|
||||
<path d="M5.40873 17.6472C6.43247 15.8556 8.3377 14.75 10.4011 14.75H13.5979C15.6613 14.75 17.5666 15.8556 18.5903 17.6472L19.6094 19.5928C17.6634 21.5432 14.9724 22.7499 11.9996 22.7499C9.0267 22.7499 6.33569 21.5431 4.38965 19.5928L5.40873 17.6472Z" fill="currentColor"></path>
|
||||
</svg>
|
||||
);
|
||||
|
||||
@@ -508,7 +520,7 @@ export const Logout02Icon = (props) => (
|
||||
|
||||
export const SmartPhone01Icon = (props) => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width={64} height={64} color={"currentColor"} fill={"none"} {...props}>
|
||||
<path d="M14.3643 1.25195C15.1368 1.25654 15.7946 1.27495 16.3428 1.34863C17.1067 1.45134 17.7692 1.67346 18.2979 2.20215C18.8265 2.73084 19.0487 3.39328 19.1514 4.15723C19.2496 4.88804 19.25 5.81361 19.25 6.94629V17.0537C19.25 18.1864 19.2496 19.112 19.1514 19.8428C19.0487 20.6067 18.8265 21.2692 18.2979 21.7979C17.7692 22.3265 17.1067 22.5487 16.3428 22.6514C15.612 22.7496 14.6864 22.75 13.5537 22.75H10.4463L9.63574 22.748C8.86316 22.7435 8.20542 22.725 7.65723 22.6514C6.89328 22.5487 6.23084 22.3265 5.70215 21.7979C5.17346 21.2692 4.95134 20.6067 4.84863 19.8428C4.75041 19.112 4.74998 18.1864 4.75 17.0537V6.94629L4.75195 6.13574C4.75654 5.36316 4.77495 4.70542 4.84863 4.15723C4.95134 3.39328 5.17346 2.73084 5.70215 2.20215C6.23084 1.67346 6.89328 1.45134 7.65723 1.34863C8.38804 1.25041 9.31361 1.24998 10.4463 1.25H13.5537L14.3643 1.25195ZM12 18C11.4477 18 11 18.4477 11 19C11 19.5523 11.4477 20 12 20C12.5523 20 13 19.5523 13 19C13 18.4477 12.5523 18 12 18Z" fill="#ffffff"></path>
|
||||
<path d="M14.3643 1.25195C15.1368 1.25654 15.7946 1.27495 16.3428 1.34863C17.1067 1.45134 17.7692 1.67346 18.2979 2.20215C18.8265 2.73084 19.0487 3.39328 19.1514 4.15723C19.2496 4.88804 19.25 5.81361 19.25 6.94629V17.0537C19.25 18.1864 19.2496 19.112 19.1514 19.8428C19.0487 20.6067 18.8265 21.2692 18.2979 21.7979C17.7692 22.3265 17.1067 22.5487 16.3428 22.6514C15.612 22.7496 14.6864 22.75 13.5537 22.75H10.4463L9.63574 22.748C8.86316 22.7435 8.20542 22.725 7.65723 22.6514C6.89328 22.5487 6.23084 22.3265 5.70215 21.7979C5.17346 21.2692 4.95134 20.6067 4.84863 19.8428C4.75041 19.112 4.74998 18.1864 4.75 17.0537V6.94629L4.75195 6.13574C4.75654 5.36316 4.77495 4.70542 4.84863 4.15723C4.95134 3.39328 5.17346 2.73084 5.70215 2.20215C6.23084 1.67346 6.89328 1.45134 7.65723 1.34863C8.38804 1.25041 9.31361 1.24998 10.4463 1.25H13.5537L14.3643 1.25195ZM12 18C11.4477 18 11 18.4477 11 19C11 19.5523 11.4477 20 12 20C12.5523 20 13 19.5523 13 19C13 18.4477 12.5523 18 12 18Z" fill="currentColor"></path>
|
||||
</svg>
|
||||
);
|
||||
|
||||
@@ -517,4 +529,12 @@ export const ComputerIcon = (props) => (
|
||||
<path d="M16.0549 2.25C17.4225 2.24998 18.5248 2.24996 19.3918 2.36652C20.2919 2.48754 21.0497 2.74643 21.6517 3.34835C22.2536 3.95027 22.5125 4.70814 22.6335 5.60825C22.75 6.47522 22.75 7.57754 22.75 8.94513V11.0549C22.75 12.4225 22.75 13.5248 22.6335 14.3918C22.5125 15.2919 22.2536 16.0497 21.6517 16.6517C21.0497 17.2536 20.2919 17.5125 19.3918 17.6335C18.5248 17.75 17.4225 17.75 16.0549 17.75H16.0549H7.94513H7.94512C6.57754 17.75 5.47522 17.75 4.60825 17.6335C3.70814 17.5125 2.95027 17.2536 2.34835 16.6517C1.74643 16.0497 1.48754 15.2919 1.36652 14.3918C1.24996 13.5248 1.24998 12.4225 1.25 11.0549V11.0549V8.94513V8.94511C1.24998 7.57753 1.24996 6.47521 1.36652 5.60825C1.48754 4.70814 1.74643 3.95027 2.34835 3.34835C2.95027 2.74643 3.70814 2.48754 4.60825 2.36652C5.47521 2.24996 6.57753 2.24998 7.94511 2.25H7.94513H16.0549H16.0549Z" fill="currentColor" />
|
||||
<path fillRule="evenodd" clipRule="evenodd" d="M10.5 16.75C10.5 16.1977 10.9477 15.75 11.5 15.75H12.5C13.0523 15.75 13.5 16.1977 13.5 16.75V19.25C13.5 19.5261 13.7239 19.75 14 19.75H16C16.5523 19.75 17 20.1977 17 20.75C17 21.3023 16.5523 21.75 16 21.75H8C7.44772 21.75 7 21.3023 7 20.75C7 20.1977 7.44772 19.75 8 19.75H10C10.2761 19.75 10.5 19.5261 10.5 19.25V16.75Z" fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const Menu01Icon = (props) => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width={64} height={64} color={"currentColor"} fill={"none"} {...props}>
|
||||
<path fillRule="evenodd" clipRule="evenodd" d="M3 5C3 4.44772 3.44772 4 4 4L20 4C20.5523 4 21 4.44772 21 5C21 5.55229 20.5523 6 20 6L4 6C3.44772 6 3 5.55228 3 5Z" fill="currentColor"></path>
|
||||
<path fillRule="evenodd" clipRule="evenodd" d="M3 12C3 11.4477 3.44772 11 4 11L20 11C20.5523 11 21 11.4477 21 12C21 12.5523 20.5523 13 20 13L4 13C3.44772 13 3 12.5523 3 12Z" fill="currentColor"></path>
|
||||
<path fillRule="evenodd" clipRule="evenodd" d="M3 19C3 18.4477 3.44772 18 4 18L20 18C20.5523 18 21 18.4477 21 19C21 19.5523 20.5523 20 20 20L4 20C3.44772 20 3 19.5523 3 19Z" fill="currentColor"></path>
|
||||
</svg>
|
||||
);
|
||||
+23
-1
@@ -19,7 +19,11 @@ import { registerStoragePolicies, clearStorageConfig } from '@zen/core/storage';
|
||||
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';
|
||||
import { PERMISSION_DEFINITIONS } from '../../core/users/constants.js';
|
||||
import { registerPermissions, clearRegisteredPermissions } from '../../core/users/permissions-registry.js';
|
||||
import { discoverModules, validateModuleEnvVars } from '../../core/modules/discover.server.js';
|
||||
import { getRegisteredModules, clearRegisteredModules } from '../../core/modules/registry.js';
|
||||
import { done, warn, fail } from './logger.js';
|
||||
|
||||
const ZEN_INIT_KEY = Symbol.for('__ZEN_INITIALIZED__');
|
||||
|
||||
@@ -38,6 +42,22 @@ export async function initializeZen() {
|
||||
configureRouter({ resolveSession: validateSession });
|
||||
registerFeatureRoutes(authRoutes);
|
||||
registerStoragePolicies(storageAccessPolicies);
|
||||
registerPermissions(PERMISSION_DEFINITIONS);
|
||||
|
||||
// Découverte et activation des modules @zen/module-*
|
||||
await discoverModules();
|
||||
const modules = getRegisteredModules();
|
||||
validateModuleEnvVars(modules);
|
||||
for (const mod of modules) {
|
||||
if (Array.isArray(mod.manifest?.permissions)) {
|
||||
registerPermissions(mod.manifest.permissions);
|
||||
}
|
||||
try {
|
||||
await mod.register();
|
||||
} catch (error) {
|
||||
fail(`zen-modules: ${mod.manifest.name} register() threw — ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
done('ZEN: ready');
|
||||
|
||||
@@ -49,5 +69,7 @@ export function resetZenInitialization() {
|
||||
clearRouterConfig();
|
||||
clearFeatureRoutes();
|
||||
clearStorageConfig();
|
||||
clearRegisteredPermissions();
|
||||
clearRegisteredModules();
|
||||
warn('ZEN: initialization reset');
|
||||
}
|
||||
|
||||
@@ -122,12 +122,12 @@ export function getIpFromHeaders(headersList) {
|
||||
const realIp = headersList.get('x-real-ip')?.trim();
|
||||
if (realIp && isValidIp(realIp)) return realIp;
|
||||
}
|
||||
// Fallback when no trusted proxy is configured.
|
||||
// Callers (router.js, authActions.js) treat 'unknown' as a signal to suspend
|
||||
// rate limiting rather than collapse all traffic into one shared bucket — which
|
||||
// would allow a single attacker to exhaust the quota and deny service globally.
|
||||
// In development, use loopback so rate limiting stays active and the
|
||||
// "IP cannot be determined" warning is not emitted.
|
||||
// In production without a trusted proxy, return 'unknown' to suspend rate
|
||||
// limiting rather than collapse all traffic into one shared bucket.
|
||||
// Configure ZEN_TRUST_PROXY=true behind a verified reverse proxy.
|
||||
return 'unknown';
|
||||
return process.env.NODE_ENV === 'development' ? '127.0.0.1' : 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -143,7 +143,7 @@ export function getIpFromRequest(request) {
|
||||
const realIp = request.headers.get('x-real-ip')?.trim();
|
||||
if (realIp && isValidIp(realIp)) return realIp;
|
||||
}
|
||||
return 'unknown';
|
||||
return process.env.NODE_ENV === 'development' ? '127.0.0.1' : 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user