Compare commits
110 Commits
50f04f762b
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 3eeaebfa68 | |||
| 4bc7319056 | |||
| d7aa3532d1 | |||
| 1fcd57807f | |||
| 4bd51bcd13 | |||
| 980f9cc5a0 | |||
| 1a132bb1af | |||
| 0d7b654a2d | |||
| 8bed913459 | |||
| 14c2c3d6bf | |||
| 60689b8c4d | |||
| 9df91bf412 | |||
| 489147d25d | |||
| 9d155a28c9 | |||
| b775b05c15 | |||
| c74737e5d9 | |||
| fdf35f36a3 | |||
| 7fa2353296 | |||
| 645a54dba5 | |||
| 54386d3fe3 | |||
| 0c99bf5002 | |||
| 4e759767f2 | |||
| 8b61da7322 | |||
| cd6064b98f | |||
| e78f5321e6 | |||
| 5474368a7e | |||
| f14731e554 | |||
| 9cdc945639 | |||
| cb547f6400 | |||
| 0b32e8aa97 | |||
| eb87d9070d | |||
| b460ed0619 | |||
| 92f3e4c561 | |||
| 1b85d6fac7 | |||
| c793bc418c | |||
| 94ab6c36cb | |||
| c9a3634fc9 | |||
| d6befcfa91 | |||
| 65e833d020 | |||
| 6b3bb6a4ee | |||
| 7d6765b58b | |||
| 7afcb2cb5a | |||
| 6bbf3f1507 | |||
| 34f0b9da22 | |||
| b17895e162 | |||
| 9f709df357 | |||
| 143d9bd2cc | |||
| 7f89c35969 | |||
| de745cb924 | |||
| 7c1341d439 | |||
| 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 | |||
| 650d2dbb27 | |||
| 2d3d450e19 | |||
| c25a518d87 | |||
| 8d5a785494 | |||
| 957e322f9f | |||
| f77635b7b3 | |||
| 47437ecca8 |
@@ -10,6 +10,9 @@ ZEN_CURRENCY=CAD
|
|||||||
ZEN_CURRENCY_SYMBOL=$
|
ZEN_CURRENCY_SYMBOL=$
|
||||||
ZEN_SUPPORT_EMAIL=support@exemple.com
|
ZEN_SUPPORT_EMAIL=support@exemple.com
|
||||||
|
|
||||||
|
# PROXY (activer si derrière un reverse proxy)
|
||||||
|
ZEN_TRUST_PROXY=false
|
||||||
|
|
||||||
# DATABASE
|
# DATABASE
|
||||||
ZEN_DATABASE_URL=postgres://USER:PASSWORD@HOST:PORT/postgres
|
ZEN_DATABASE_URL=postgres://USER:PASSWORD@HOST:PORT/postgres
|
||||||
ZEN_DATABASE_URL_DEV=postgres://USER:PASSWORD@HOST:PORT/postgres_dev
|
ZEN_DATABASE_URL_DEV=postgres://USER:PASSWORD@HOST:PORT/postgres_dev
|
||||||
|
|||||||
@@ -1,3 +1,13 @@
|
|||||||
# Claude Code Rules
|
# 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.
|
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.
|
||||||
|
|
||||||
|
## No whack-a-mole
|
||||||
|
|
||||||
|
Always fix the **root cause**, never the symptoms. If the same type of error appears in a second file after fixing the first, that's the signal the real fix is elsewhere — trace the import or call chain to understand why this code is reached in this context, then fix at that point.
|
||||||
|
|||||||
+9
-1
@@ -12,12 +12,16 @@ Pour la procédure de publication du package : [PUBLICATION.md](./dev/PUBLICATIO
|
|||||||
|
|
||||||
Pour les conventions de commit : [COMMITS.md](./dev/COMMITS.md).
|
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.
|
> **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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Standards de code
|
## Standards de code
|
||||||
|
|
||||||
|
**On corrige la cause racine, jamais les symptômes.** Si le même type d'erreur réapparaît dans un deuxième fichier après avoir corrigé le premier, c'est le signe que le vrai fix est ailleurs. Remonter la chaîne d'imports ou d'appels jusqu'à comprendre *pourquoi* ce code est atteint dans ce contexte, puis corriger à cet endroit. Patcher les fichiers un par un (whack-a-mole) masque le problème et garantit qu'un troisième cas surgira.
|
||||||
|
|
||||||
**Les promesses ne s'ignorent pas.** Chaque `Promise` est `await`ée ou `.catch()`ée. Une promesse silencieuse qui échoue est un bug invisible.
|
**Les promesses ne s'ignorent pas.** Chaque `Promise` est `await`ée ou `.catch()`ée. Une promesse silencieuse qui échoue est un bug invisible.
|
||||||
|
|
||||||
**Les variables d'environnement et la documentation se mettent à jour avec le code.** Toute variable ajoutée, renommée ou supprimée doit être reflétée dans `.env.example`. Toute décision architecturale ou convention nouvelle doit être documentée dans le fichier `docs/` concerné.
|
**Les variables d'environnement et la documentation se mettent à jour avec le code.** Toute variable ajoutée, renommée ou supprimée doit être reflétée dans `.env.example`. Toute décision architecturale ou convention nouvelle doit être documentée dans le fichier `docs/` concerné.
|
||||||
@@ -54,6 +58,8 @@ Tout fichier épinglé à une frontière Next.js porte le suffixe dans son nom :
|
|||||||
|
|
||||||
Ces suffixes ne sont pas cosmétiques : **le build les utilise comme source de vérité**. Le build compile l'intégralité de `src/` avec `bundle: false` — chaque fichier reste un module séparé, ce qui permet à Next.js de respecter les frontières RSC et `'use client'` sans que le bundler ne fusionne les modules.
|
Ces suffixes ne sont pas cosmétiques : **le build les utilise comme source de vérité**. Le build compile l'intégralité de `src/` avec `bundle: false` — chaque fichier reste un module séparé, ce qui permet à Next.js de respecter les frontières RSC et `'use client'` sans que le bundler ne fusionne les modules.
|
||||||
|
|
||||||
|
**Tout fichier qui `import 'pg'`, `'fs'`, `'net'`, `'node:*'` doit porter `.server.js`.** Sans ce suffixe, le fichier est réputé neutre — un barrel client peut le ré-exporter par mégarde, et Turbopack/Webpack tracent la chaîne d'imports statiques jusqu'à `pg` dans le bundle browser. L'erreur `Module not found: Can't resolve 'dns'` côté client est typiquement causée par un fichier serveur sans suffixe atteint via un barrel mixte.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Build et configuration tsup
|
## Build et configuration tsup
|
||||||
@@ -71,6 +77,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.
|
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
|
```js
|
||||||
// app/zen.extensions.js — projet consommateur
|
// app/zen.extensions.js — projet consommateur
|
||||||
import {
|
import {
|
||||||
@@ -88,7 +96,7 @@ registerWidgetFetcher('orders', async () => ({ total: await countOrders() }));
|
|||||||
registerWidget({ id: 'orders', Component: OrdersWidget, order: 20 });
|
registerWidget({ id: 'orders', Component: OrdersWidget, order: 20 });
|
||||||
|
|
||||||
registerNavSection({ id: 'commerce', title: 'Commerce', icon: 'ShoppingBag03Icon', order: 30 });
|
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' });
|
registerPage({ slug: 'orders', Component: OrdersPage, title: 'Commandes' });
|
||||||
```
|
```
|
||||||
|
|||||||
+449
@@ -0,0 +1,449 @@
|
|||||||
|
# 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
|
||||||
|
|
||||||
|
Les modules sont activés via deux **manifestes statiques** générés par la CLI `zen-modules sync` dans le projet consommateur :
|
||||||
|
|
||||||
|
- `app/.zen/modules.generated.js` — manifeste serveur (importé par `instrumentation.js`).
|
||||||
|
- `app/.zen/modules.client.js` — manifeste client (`'use client'`, exporte un Component à rendre dans `app/layout.js`).
|
||||||
|
|
||||||
|
Le manifeste serveur peuple le registre côté Node via le top-level await à l'import. Le manifeste client exporte `ZenModulesClient` — un Component React — que `app/layout.js` doit **rendre** dans son tree (pas seulement importer). Sous Next.js 15+/Turbopack, un `import` purement side-effect d'un fichier `'use client'` orphelin (sans Component utilisé) inclut bien le fichier dans le bundle browser mais n'en exécute pas le code top-level — `registerPage()` / `registerWidget()` ne tourneraient jamais côté client. Rendre le Component force l'exécution.
|
||||||
|
|
||||||
|
```js
|
||||||
|
// app/.zen/modules.generated.js — AUTO-GÉNÉRÉ (serveur)
|
||||||
|
import * as m0_zen_module_posts from '@zen/module-posts';
|
||||||
|
export const modules = [
|
||||||
|
{ name: '@zen/module-posts', exports: m0_zen_module_posts },
|
||||||
|
];
|
||||||
|
await Promise.all(modules.map(m => m.exports.register?.()));
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
// app/.zen/modules.client.js — AUTO-GÉNÉRÉ (client)
|
||||||
|
'use client';
|
||||||
|
import '@zen/module-posts/client';
|
||||||
|
export default function ZenModulesClient() {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
// app/layout.js — projet consommateur
|
||||||
|
import ZenModulesClient from './.zen/modules.client.js';
|
||||||
|
|
||||||
|
export default function RootLayout({ children }) {
|
||||||
|
return (
|
||||||
|
<html>
|
||||||
|
<body>
|
||||||
|
<ZenModulesClient />
|
||||||
|
{children}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Le manifeste client importe la **sous-entrée `./client`** de chaque module (et non son main entry). C'est essentiel : le main entry tire `createTables` / `registerApiRoutes` / la chaîne `register-server.js` qui dépend de `pg`, `fs`, `next/headers`, etc. — code serveur incompatible avec le bundle browser. Seuls les modules qui exposent `./client` dans leur `package.json#exports` sont inclus dans le manifeste client ; les modules purement back-end (API/DB) sont absents du bundle browser.
|
||||||
|
|
||||||
|
L'importation statique (le `import * as ...`) permet à Turbopack/Webpack d'analyser le graphe complet du module — JSX, `next/headers`, `next/navigation`, frontières `'use client'` — exactement comme pour le code de l'application elle-même.
|
||||||
|
|
||||||
|
### Critères de détection
|
||||||
|
|
||||||
|
`zen-modules sync` scanne `dependencies` + `devDependencies` du `package.json` du projet et inclut 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"]`
|
||||||
|
|
||||||
|
### Quand resync ?
|
||||||
|
|
||||||
|
Le template `@zen/start` câble la CLI dans :
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"scripts": {
|
||||||
|
"postinstall": "zen-modules sync",
|
||||||
|
"dev": "zen-modules sync && next dev",
|
||||||
|
"build": "zen-modules sync && next build"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`postinstall` couvre `npm install @zen/module-X`. Les hooks `dev`/`build` couvrent les retraits / changements de version qui ne déclenchent pas de re-install. La commande est idempotente — pas d'écriture si le contenu est identique.
|
||||||
|
|
||||||
|
`app/.zen/modules.generated.js` est gitignoré : régénéré localement, jamais commit.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Forme d'un module
|
||||||
|
|
||||||
|
Le point d'entrée du package (`main` ou `exports["."]`) doit exporter :
|
||||||
|
|
||||||
|
```js
|
||||||
|
// @zen/module-blog/src/index.js (compilé vers dist/index.js par tsup)
|
||||||
|
|
||||||
|
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`.
|
||||||
|
|
||||||
|
Pour l'en-tête des pages admin (titre, description, bouton retour, action), utiliser `AdminHeader` importé depuis `@zen/core/features/admin/components` :
|
||||||
|
|
||||||
|
```js
|
||||||
|
'use client';
|
||||||
|
import { AdminHeader } from '@zen/core/features/admin/components';
|
||||||
|
|
||||||
|
// Page liste avec bouton d'action
|
||||||
|
export default function BlogListPage() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<AdminHeader
|
||||||
|
title="Billets"
|
||||||
|
description="Billets disponibles."
|
||||||
|
action={<Button onClick={...}>Créer un billet</Button>}
|
||||||
|
/>
|
||||||
|
{/* ... */}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Page formulaire avec bouton retour
|
||||||
|
export default function BlogCreatePage() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<AdminHeader
|
||||||
|
title="Créer un billet"
|
||||||
|
description="Remplir les champs et enregistrer."
|
||||||
|
backHref="/admin/blog"
|
||||||
|
/>
|
||||||
|
{/* ... */}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Prop | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| `title` | `string` | Titre de la page. |
|
||||||
|
| `description` | `string` | Sous-titre optionnel. |
|
||||||
|
| `backHref` | `string` | Si fourni, affiche un bouton « ← Retour » qui navigue vers cette URL. |
|
||||||
|
| `backLabel` | `string` | Label du bouton retour (défaut : `← Retour`). |
|
||||||
|
| `action` | `ReactNode` | Élément affiché à droite (bouton créer, etc.). |
|
||||||
|
|
||||||
|
### 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
|
||||||
|
|
||||||
|
Un module avec partie admin expose **deux entrées** dans son `package.json#exports` :
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"exports": {
|
||||||
|
".": { "import": "./dist/index.js" },
|
||||||
|
"./client": { "import": "./dist/client.js" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Entrée | Bundle | Contenu |
|
||||||
|
|--------|--------|---------|
|
||||||
|
| `.` (main) | serveur | `manifest`, `register()`, `createTables`, `dropTables`. Le `register()` tire la chaîne `register-server.js` (API routes, navigation, fetchers, storage prefixes, hooks DB). |
|
||||||
|
| `./client` | client | `'use client'` ; uniquement `registerPage({ Component })` et `registerWidget({ Component })`. Aucun import vers `pg`, `fs`, `.server.js` ou `register-server.js`. |
|
||||||
|
|
||||||
|
Le manifeste serveur importe `@zen/module-X` (main) ; le manifeste client importe `@zen/module-X/client`. La séparation est obligatoire : tout ce qui est statiquement importé depuis l'entrée client finit dans le bundle browser, et `pg` / `fs` / `next/headers` y crashent.
|
||||||
|
|
||||||
|
```js
|
||||||
|
// src/index.js (entrée serveur)
|
||||||
|
export const manifest = { /* ... */ };
|
||||||
|
export async function register() { await import('./register-server.js'); }
|
||||||
|
export { createTables, dropTables } from './db.server.js';
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
// src/register-server.js (server-only — appelée par register())
|
||||||
|
import { registerNavItem, registerNavSection } from '@zen/core/features/admin';
|
||||||
|
import { registerApiRoutes } from '@zen/core/api';
|
||||||
|
import { routes } from './api.server.js';
|
||||||
|
// PAS d'import de Component .client.js ici — ils vivent dans client.js.
|
||||||
|
registerNavSection({ /* ... */ });
|
||||||
|
registerApiRoutes(routes);
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
// src/client.js (entrée client — chargée par le manifeste client uniquement)
|
||||||
|
'use client';
|
||||||
|
import { registerPage } from '@zen/core/features/admin';
|
||||||
|
import BlogAdminPage from './admin/BlogAdminPage.client.js';
|
||||||
|
registerPage({ slug: 'blog', Component: BlogAdminPage, title: 'Blog' });
|
||||||
|
```
|
||||||
|
|
||||||
|
### Sous-entrées `@zen/core/*` safe-pour-client
|
||||||
|
|
||||||
|
Le `client.js` d'un module ne doit jamais importer un barrel mixte (un barrel qui ré-exporte du code serveur à côté de constantes client-safe). Si on le fait, Turbopack/Webpack tracent toute la chaîne d'imports statiques et ramènent `pg`/`fs`/`next/headers` dans le bundle browser — qui crashe avec `Module not found: Can't resolve 'dns'` ou équivalent.
|
||||||
|
|
||||||
|
Sous-entrées explicitement safe pour un import depuis un fichier `'use client'` :
|
||||||
|
|
||||||
|
| Sous-entrée | Contenu |
|
||||||
|
|-------------|---------|
|
||||||
|
| `@zen/core/users/constants` | `PERMISSIONS`, `PERMISSION_DEFINITIONS`, `getPermissionGroups` — aucun import serveur. |
|
||||||
|
| `@zen/core/features/admin` | `registerPage`, `registerWidget`, `registerNavItem`, `registerNavSection`, `buildNavigationSections`. Neutre côté boundary. |
|
||||||
|
| `@zen/core/features/admin/components` | Composants client : `AdminHeader`, `AdminShell`, `AdminSidebar`, `ThemeToggle`, modals. |
|
||||||
|
| `@zen/core/themes` | Tokens/utilitaires de thème. |
|
||||||
|
| `@zen/core/toast` | API toast côté client. |
|
||||||
|
| `@zen/core/shared/icons` | Composants d'icônes. |
|
||||||
|
| `@zen/core/shared/components` | Composants partagés. |
|
||||||
|
|
||||||
|
Tout ce qui n'est pas dans cette liste — en particulier `@zen/core/users` (barrel complet), `@zen/core/database`, `@zen/core/api`, `@zen/core/storage` — est du code serveur. Ne JAMAIS l'importer depuis un fichier `'use client'` ou un fichier transitivement importé par un `'use client'`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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: "./dist/index.js"
|
||||||
|
├── tsup.config.js # build avec bundle: false, loader JSX, outbase: 'src'
|
||||||
|
├── README.md # documente les env vars et la configuration
|
||||||
|
├── src/
|
||||||
|
│ ├── index.js # exporte manifest, register, createTables, dropTables
|
||||||
|
│ ├── db.server.js # createTables/dropTables
|
||||||
|
│ ├── register-server.js # imports déclencheurs (chargé par register())
|
||||||
|
│ ├── api.server.js # routes API (registerApiRoutes)
|
||||||
|
│ ├── admin/
|
||||||
|
│ │ ├── BlogAdminPage.client.js # registerPage + composant
|
||||||
|
│ │ └── widgets/... # registerWidgetFetcher + registerWidget
|
||||||
|
│ └── public/
|
||||||
|
│ └── BlogPublicPage.js # registerPublicModulePage
|
||||||
|
└── dist/ # généré par `npm run build` — c'est ce qui est publié
|
||||||
|
```
|
||||||
|
|
||||||
|
Le champ `files` dans `package.json` publie **uniquement** `dist/`, `README.md` et `LICENSE` :
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"main": "./dist/index.js",
|
||||||
|
"exports": { ".": { "import": "./dist/index.js" } },
|
||||||
|
"files": ["dist", "README.md", "LICENSE"],
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsup",
|
||||||
|
"prepublishOnly": "npm run build"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Build avant publish
|
||||||
|
|
||||||
|
**Tout module `@zen/module-*` est pré-compilé avant publication**, comme `@zen/core` lui-même. Le build `tsup` avec `bundle: false` transforme le JSX, préserve les directives `'use client'` en haut des fichiers compilés, et garde un fichier d'entrée par fichier de sortie pour respecter les frontières RSC quand le projet consommateur bundle le module.
|
||||||
|
|
||||||
|
C'est ce qui permet au manifeste statique généré par `zen-modules sync` de simplement faire `import * as ... from '@zen/module-X'` et de laisser Turbopack/Webpack composer le reste : aucune transformation runtime n'est requise côté consommateur.
|
||||||
|
|
||||||
|
### Exemple de `tsup.config.js` minimal
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { defineConfig } from 'tsup';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
entry: ['src/**/*.js', 'src/**/*.jsx'],
|
||||||
|
format: ['esm'],
|
||||||
|
outDir: 'dist',
|
||||||
|
outbase: 'src',
|
||||||
|
bundle: false,
|
||||||
|
splitting: false,
|
||||||
|
clean: true,
|
||||||
|
loader: { '.js': 'jsx' },
|
||||||
|
jsx: 'automatic',
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Vérifier qu'un module est correctement compilé
|
||||||
|
|
||||||
|
Avant `npm publish`, ouvrir `dist/` et confirmer qu'aucun fichier ne contient de syntaxe JSX brute (chercher `<` suivi d'une majuscule). Tous les composants doivent apparaître sous forme d'appels `jsx(...)` ou `React.createElement(...)`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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 |
|
||||||
@@ -47,3 +47,25 @@ src/
|
|||||||
| Stockage | Tout accès fichier passe par `core/storage`. |
|
| Stockage | Tout accès fichier passe par `core/storage`. |
|
||||||
| Notifications | Un seul système de toast dans toute l'app : `core/toast`. |
|
| Notifications | Un seul système de toast dans toute l'app : `core/toast`. |
|
||||||
| Authentification | Toute auth de site passe par `features/auth`. |
|
| Authentification | Toute auth de site passe par `features/auth`. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Pas de `next/headers` ni `next/navigation` au top-level dans la chaîne `register()` des modules
|
||||||
|
|
||||||
|
Tout fichier qui peut être atteint depuis le `register()` d'un module externe (directement ou transitivement, via un barrel `@zen/core/*` qu'il importe) **ne doit pas** importer `next/headers` ou `next/navigation` au top-level. L'import doit être lazy à l'intérieur du handler :
|
||||||
|
|
||||||
|
```js
|
||||||
|
export async function handleSomething(request) {
|
||||||
|
const { cookies } = await import('next/headers');
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Pourquoi.** [`discover.server.js`](../src/core/modules/discover.server.js) charge chaque module via `import(/* turbopackIgnore */ name)` pour empêcher Turbopack/Webpack de bundler un nom dynamique. Conséquence : Node résout tout l'arbre d'imports transitifs en ESM natif, **sans** la condition `react-server`. Or `next/headers` (Next.js 15+) n'est exposé que via cette condition — un import top-level échoue alors avec `Cannot find module 'next/headers'`.
|
||||||
|
|
||||||
|
**Conséquences pratiques.**
|
||||||
|
|
||||||
|
- Les fichiers qui ont besoin de Next.js au top-level (par ex. [`features/admin/protect.js`](../src/features/admin/protect.js), [`features/auth/actions.js`](../src/features/auth/actions.js)) ne sont jamais réexportés par les barrels accessibles aux modules ; ils sont exposés via des sous-chemins dédiés dans `package.json#exports` (ex. `@zen/core/features/admin/protect`).
|
||||||
|
- Pour les handlers qui restent dans un barrel exposé (ex. [`core/api/router.js`](../src/core/api/router.js), [`core/storage/api.js`](../src/core/storage/api.js)), `cookies` et compagnie sont importés en lazy à l'intérieur du handler.
|
||||||
|
|
||||||
|
Avant d'ajouter un `import { cookies } from 'next/headers'` à un fichier core, vérifier qu'aucun barrel exposé aux modules ne le tire transitivement. Sinon, garder l'import lazy.
|
||||||
Generated
+4
-3
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@zen/core",
|
"name": "@zen/core",
|
||||||
"version": "1.4.96",
|
"version": "1.4.146",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@zen/core",
|
"name": "@zen/core",
|
||||||
"version": "1.4.96",
|
"version": "1.4.146",
|
||||||
"license": "GPL-3.0-only",
|
"license": "GPL-3.0-only",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@headlessui/react": "^2.0.0",
|
"@headlessui/react": "^2.0.0",
|
||||||
@@ -19,7 +19,8 @@
|
|||||||
"stripe": "^14.0.0"
|
"stripe": "^14.0.0"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"zen-db": "dist/core/database/cli.js"
|
"zen-db": "dist/core/database/cli.js",
|
||||||
|
"zen-modules": "dist/core/modules/cli.js"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
|
|||||||
+15
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@zen/core",
|
"name": "@zen/core",
|
||||||
"version": "1.4.96",
|
"version": "1.4.146",
|
||||||
"description": "Une plateforme multi-usage construite sur l'essentiel, rien de plus, rien de moins.",
|
"description": "Une plateforme multi-usage construite sur l'essentiel, rien de plus, rien de moins.",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
@@ -25,7 +25,8 @@
|
|||||||
"release": "npm version patch --no-git-tag-version && npm i && git add package.json package-lock.json && git commit -m \"chore: bump version to $(node -p \"require('./package.json').version\")\" && git push && npm publish"
|
"release": "npm version patch --no-git-tag-version && npm i && git add package.json package-lock.json && git commit -m \"chore: bump version to $(node -p \"require('./package.json').version\")\" && git push && npm publish"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"zen-db": "./dist/core/database/cli.js"
|
"zen-db": "./dist/core/database/cli.js",
|
||||||
|
"zen-modules": "./dist/core/modules/cli.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@headlessui/react": "^2.0.0",
|
"@headlessui/react": "^2.0.0",
|
||||||
@@ -72,6 +73,9 @@
|
|||||||
"./features/admin": {
|
"./features/admin": {
|
||||||
"import": "./dist/features/admin/index.js"
|
"import": "./dist/features/admin/index.js"
|
||||||
},
|
},
|
||||||
|
"./features/admin/protect": {
|
||||||
|
"import": "./dist/features/admin/protect.js"
|
||||||
|
},
|
||||||
"./features/admin/server": {
|
"./features/admin/server": {
|
||||||
"import": "./dist/features/admin/AdminPage.server.js"
|
"import": "./dist/features/admin/AdminPage.server.js"
|
||||||
},
|
},
|
||||||
@@ -93,6 +97,15 @@
|
|||||||
"./users/constants": {
|
"./users/constants": {
|
||||||
"import": "./dist/core/users/constants.js"
|
"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": {
|
"./api": {
|
||||||
"import": "./dist/core/api/index.js"
|
"import": "./dist/core/api/index.js"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ route-handler.js (GET / POST / PUT / DELETE / PATCH)
|
|||||||
├─ matchRoute(pattern, path) — exact, :param, /**
|
├─ matchRoute(pattern, path) — exact, :param, /**
|
||||||
├─ Auth enforcement (depuis la définition de la route)
|
├─ Auth enforcement (depuis la définition de la route)
|
||||||
│ 'admin' → requireAdmin() — session dans context.session
|
│ 'admin' → requireAdmin() — session dans context.session
|
||||||
|
│ │ si `permission` est défini → hasPermission() → 403 si refusé
|
||||||
│ 'user' → requireAuth() — session dans context.session
|
│ 'user' → requireAuth() — session dans context.session
|
||||||
│ 'public'→ aucun — context.session = undefined
|
│ 'public'→ aucun — context.session = undefined
|
||||||
└─ handler(request, params, context)
|
└─ handler(request, params, context)
|
||||||
@@ -175,6 +176,13 @@ Champs requis par route :
|
|||||||
| `handler` | `Function` | Signature : `(request, params, context) => Promise<Object>` |
|
| `handler` | `Function` | Signature : `(request, params, context) => Promise<Object>` |
|
||||||
| `auth` | `string` | `'public'` \| `'user'` \| `'admin'` |
|
| `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
|
## Note — handler storage
|
||||||
|
|||||||
@@ -23,6 +23,10 @@
|
|||||||
* check for this route. Use sparingly — only for routes
|
* check for this route. Use sparingly — only for routes
|
||||||
* that must remain accessible under high probe frequency
|
* that must remain accessible under high probe frequency
|
||||||
* (e.g. health checks from monitoring systems).
|
* (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:
|
* Auth levels:
|
||||||
* 'public' Anyone can call this route. context.session is undefined.
|
* '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)}`
|
`${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.
|
// Freeze to prevent accidental mutation of route definitions at runtime.
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
export { routeRequest, requireAuth, requireAdmin } from './router.js';
|
export { routeRequest, requireAuth, requireAdmin } from './router.js';
|
||||||
|
|
||||||
// Runtime state — session resolver + feature routes registry
|
// 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)
|
// Response utilities — use in all handlers (core and modules)
|
||||||
export { apiSuccess, apiError, getStatusCode } from './respond.js';
|
export { apiSuccess, apiError, getStatusCode } from './respond.js';
|
||||||
|
|||||||
@@ -18,7 +18,6 @@
|
|||||||
* → handler(request, params, context)
|
* → handler(request, params, context)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { cookies } from 'next/headers';
|
|
||||||
import { getSessionCookieName } from '@zen/core/shared/config';
|
import { getSessionCookieName } from '@zen/core/shared/config';
|
||||||
import { checkRateLimit, getIpFromRequest, formatRetryAfter } from '@zen/core/shared/rate-limit';
|
import { checkRateLimit, getIpFromRequest, formatRetryAfter } from '@zen/core/shared/rate-limit';
|
||||||
import { fail } from '@zen/core/shared/logger';
|
import { fail } from '@zen/core/shared/logger';
|
||||||
@@ -40,6 +39,7 @@ const COOKIE_NAME = getSessionCookieName();
|
|||||||
* @returns {Promise<Object>} session
|
* @returns {Promise<Object>} session
|
||||||
*/
|
*/
|
||||||
export async function requireAuth() {
|
export async function requireAuth() {
|
||||||
|
const { cookies } = await import('next/headers');
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
const sessionToken = cookieStore.get(COOKIE_NAME)?.value;
|
const sessionToken = cookieStore.get(COOKIE_NAME)?.value;
|
||||||
|
|
||||||
@@ -271,6 +271,12 @@ export async function routeRequest(request, path) {
|
|||||||
try {
|
try {
|
||||||
if (matchedRoute.auth === 'admin') {
|
if (matchedRoute.auth === 'admin') {
|
||||||
context.session = await requireAdmin();
|
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') {
|
} else if (matchedRoute.auth === 'user') {
|
||||||
context.session = await requireAuth();
|
context.session = await requireAuth();
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-4
@@ -68,18 +68,25 @@ if (!globalThis[REGISTRY_KEY]) globalThis[REGISTRY_KEY] = [];
|
|||||||
const _featureRoutes = globalThis[REGISTRY_KEY];
|
const _featureRoutes = globalThis[REGISTRY_KEY];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Enregistre les routes d'une feature core.
|
* Enregistre des routes API.
|
||||||
* Appelé une fois par feature pendant initializeZen().
|
* 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()
|
* @param {ReadonlyArray} routes - Définitions produites par defineApiRoutes()
|
||||||
*/
|
*/
|
||||||
export function registerFeatureRoutes(routes) {
|
export function registerApiRoutes(routes) {
|
||||||
if (!Array.isArray(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);
|
_featureRoutes.push(...routes);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alias rétro-compatible de registerApiRoutes.
|
||||||
|
* @deprecated Utiliser registerApiRoutes.
|
||||||
|
*/
|
||||||
|
export const registerFeatureRoutes = registerApiRoutes;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retourne toutes les routes de features enregistrées.
|
* Retourne toutes les routes de features enregistrées.
|
||||||
* Appelé à chaque requête par le router pour construire la liste complète.
|
* 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
|
||||||
|
```
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
* Provides convenient methods for Create, Read, Update, Delete operations
|
* Provides convenient methods for Create, Read, Update, Delete operations
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { query, queryOne, queryAll } from './db.js';
|
import { query, queryOne, queryAll } from './db.server.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Filter a data object to only the columns present in allowedColumns.
|
* Filter a data object to only the columns present in allowedColumns.
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export {
|
|||||||
closePool,
|
closePool,
|
||||||
testConnection,
|
testConnection,
|
||||||
tableExists
|
tableExists
|
||||||
} from './db.js';
|
} from './db.server.js';
|
||||||
|
|
||||||
// CRUD helper functions
|
// CRUD helper functions
|
||||||
export {
|
export {
|
||||||
|
|||||||
@@ -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,47 @@
|
|||||||
|
# Modules
|
||||||
|
|
||||||
|
Registre runtime des modules `@zen/module-*` activés dans le projet consommateur. Voir [docs/MODULES.md](../../../docs/MODULES.md) pour le guide complet de création d'un module.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
Les modules sont activés via un **manifeste statique** généré dans le projet consommateur (`app/.zen/modules.generated.js`). Le manifeste fait des `import * as ...` statiques pour chaque package et appelle `register()` au top level. Importé par `instrumentation.js` (serveur) et `app/layout.js` (client), il rend l'arbre d'imports du module visible aux deux bundles Next.js — Turbopack et Webpack le bundlent comme n'importe quel autre fichier source.
|
||||||
|
|
||||||
|
Le manifeste est régénéré par `npx zen-modules sync` (typiquement depuis le `postinstall` + les scripts `dev` / `build` du projet).
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
```js
|
||||||
|
import {
|
||||||
|
registerModules,
|
||||||
|
registerModule,
|
||||||
|
getRegisteredModules,
|
||||||
|
getRegisteredModule,
|
||||||
|
findInstalledModuleNames,
|
||||||
|
validateModuleEnvVars,
|
||||||
|
} from '@zen/core/modules';
|
||||||
|
```
|
||||||
|
|
||||||
|
| Fonction | Usage |
|
||||||
|
|----------|-------|
|
||||||
|
| `registerModules(modules)` | Appelée par `initializeZen({ modules })`. Peuple le registre interne à partir du manifeste. |
|
||||||
|
| `registerModule(mod)` | Bas niveau — utilisé par `registerModules`. |
|
||||||
|
| `getRegisteredModules()` | Retourne tous les modules connus (utilisé par `zen-db init` et l'env validation). |
|
||||||
|
| `findInstalledModuleNames({ cwd })` | Scan readonly du `package.json` du projet — utilisé par le CLI `zen-modules sync`. |
|
||||||
|
| `validateModuleEnvVars(modules)` | Logge un warning par variable d'env requise absente. |
|
||||||
|
|
||||||
|
## 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 |
|
||||||
|
|
||||||
|
Le code du module doit être pré-compilé avant publication (transformation JSX, voir [docs/MODULES.md](../../../docs/MODULES.md)).
|
||||||
|
|
||||||
|
## CLI
|
||||||
|
|
||||||
|
`npx zen-modules sync` — régénère `app/.zen/modules.generated.js`. Idempotent : pas d'écriture si le contenu est inchangé.
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zen Modules CLI
|
||||||
|
*
|
||||||
|
* Génère `app/.zen/modules.generated.js` à partir des dépendances `@zen/module-*`
|
||||||
|
* détectées dans le `package.json` du projet consommateur. Le fichier généré est
|
||||||
|
* ensuite importé par `instrumentation.js` (côté serveur) et par `app/layout.js`
|
||||||
|
* (côté client) pour rendre les modules visibles aux deux bundles Next.js.
|
||||||
|
*
|
||||||
|
* Usage : `npx zen-modules sync`
|
||||||
|
*
|
||||||
|
* Conçu pour être appelé depuis postinstall + dev/build du projet consommateur.
|
||||||
|
* Idempotent : si le contenu généré est identique au fichier existant, aucune
|
||||||
|
* écriture n'est effectuée.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { writeFile, mkdir, readFile } from 'node:fs/promises';
|
||||||
|
import { resolve, dirname } from 'node:path';
|
||||||
|
import { step, done, warn, fail } from '@zen/core/shared/logger';
|
||||||
|
import { findInstalledModuleNames, moduleHasClientEntry } from './discover.server.js';
|
||||||
|
|
||||||
|
const OUTPUT_SERVER = 'app/.zen/modules.generated.js';
|
||||||
|
const OUTPUT_CLIENT = 'app/.zen/modules.client.js';
|
||||||
|
|
||||||
|
function safeIdentifier(name, idx) {
|
||||||
|
// `@zen/module-posts` → `m0_zen_module_posts`
|
||||||
|
const cleaned = name.replace(/[^a-zA-Z0-9]/g, '_');
|
||||||
|
return `m${idx}_${cleaned}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderServerManifest(names) {
|
||||||
|
const header = '// AUTO-GÉNÉRÉ par `npx zen-modules sync` — ne pas modifier à la main.\n';
|
||||||
|
|
||||||
|
if (names.length === 0) {
|
||||||
|
return header + '\nexport const modules = [];\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
const imports = names
|
||||||
|
.map((name, i) => `import * as ${safeIdentifier(name, i)} from '${name}';`)
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
const entries = names
|
||||||
|
.map((name, i) => ` { name: '${name}', exports: ${safeIdentifier(name, i)} },`)
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
return [
|
||||||
|
header,
|
||||||
|
imports,
|
||||||
|
'',
|
||||||
|
'export const modules = [',
|
||||||
|
entries,
|
||||||
|
'];',
|
||||||
|
'',
|
||||||
|
'// Top-level await : déclenche register() de chaque module au moment de l\'import',
|
||||||
|
'// côté serveur. instrumentation.js importe ce fichier au boot.',
|
||||||
|
'await Promise.all(modules.map(m => m.exports.register?.()));',
|
||||||
|
'',
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderClientManifest(clientNames) {
|
||||||
|
// Exporte un Component React `ZenModulesClient` que le layout consommateur doit
|
||||||
|
// RENDRE dans son tree (`<ZenModulesClient />`). C'est le seul moyen fiable
|
||||||
|
// sous Next.js 15+/Turbopack pour garantir que les side-effects top-level
|
||||||
|
// (registerPage, registerWidget) s'exécutent côté browser. Un simple
|
||||||
|
// `import './.zen/modules.client.js'` dans un Server Component met bien le
|
||||||
|
// fichier dans le bundle client mais n'en exécute jamais le code top-level
|
||||||
|
// — la transformation 'use client' s'applique aux Components, pas aux
|
||||||
|
// side-effect imports orphelins.
|
||||||
|
//
|
||||||
|
// Importe `@zen/module-X/client` (sous-entrée 'use client') et NON le main
|
||||||
|
// entry du module — le main entry tire createTables/registerApiRoutes/etc.
|
||||||
|
// qui dépendent de pg/fs/net et ne peuvent pas être bundlés pour le browser.
|
||||||
|
const header = [
|
||||||
|
'// AUTO-GÉNÉRÉ par `npx zen-modules sync` — ne pas modifier à la main.',
|
||||||
|
"'use client';",
|
||||||
|
'',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
const body = [
|
||||||
|
'export default function ZenModulesClient() {',
|
||||||
|
' return null;',
|
||||||
|
'}',
|
||||||
|
'',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
if (clientNames.length === 0) return header + '\n' + body;
|
||||||
|
|
||||||
|
const imports = clientNames.map(name => `import '${name}/client';`).join('\n');
|
||||||
|
return [header, imports, '', body].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readIfExists(path) {
|
||||||
|
try {
|
||||||
|
return await readFile(path, 'utf-8');
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeIfChanged(path, contents) {
|
||||||
|
const prev = await readIfExists(path);
|
||||||
|
if (prev === contents) return false;
|
||||||
|
await mkdir(dirname(path), { recursive: true });
|
||||||
|
await writeFile(path, contents, 'utf-8');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncCommand({ cwd = process.cwd() } = {}) {
|
||||||
|
const names = await findInstalledModuleNames({ cwd });
|
||||||
|
const clientNames = [];
|
||||||
|
for (const name of names) {
|
||||||
|
if (await moduleHasClientEntry(name, cwd)) clientNames.push(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
const serverCount = `(${names.length} module${names.length === 1 ? '' : 's'})`;
|
||||||
|
const clientCount = `(${clientNames.length} client entr${clientNames.length === 1 ? 'y' : 'ies'})`;
|
||||||
|
|
||||||
|
const serverPath = resolve(cwd, OUTPUT_SERVER);
|
||||||
|
const clientPath = resolve(cwd, OUTPUT_CLIENT);
|
||||||
|
|
||||||
|
const wroteServer = await writeIfChanged(serverPath, renderServerManifest(names));
|
||||||
|
const wroteClient = await writeIfChanged(clientPath, renderClientManifest(clientNames));
|
||||||
|
|
||||||
|
if (!wroteServer && !wroteClient) {
|
||||||
|
step(`zen-modules: manifests already up to date ${serverCount} ${clientCount}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wroteServer) done(`zen-modules: wrote ${OUTPUT_SERVER} ${serverCount}`);
|
||||||
|
if (wroteClient) done(`zen-modules: wrote ${OUTPUT_CLIENT} ${clientCount}`);
|
||||||
|
for (const name of names) {
|
||||||
|
const hasClient = clientNames.includes(name);
|
||||||
|
step(` → ${name}${hasClient ? ' (+ /client)' : ''}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function printHelp() {
|
||||||
|
console.log(`
|
||||||
|
Zen Modules CLI
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
npx zen-modules <command>
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
sync Régénère app/.zen/modules.generated.js à partir des @zen/module-*
|
||||||
|
déclarés dans le package.json du projet courant.
|
||||||
|
help Affiche cette aide.
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [, , command] = process.argv;
|
||||||
|
|
||||||
|
try {
|
||||||
|
switch (command) {
|
||||||
|
case 'sync':
|
||||||
|
await syncCommand();
|
||||||
|
break;
|
||||||
|
case 'help':
|
||||||
|
case '--help':
|
||||||
|
case '-h':
|
||||||
|
case undefined:
|
||||||
|
printHelp();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
warn(`Unknown command: ${command}`);
|
||||||
|
printHelp();
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
fail(`zen-modules: ${err.message}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { resolve, join, dirname } from 'node:path';
|
||||||
|
import { pathToFileURL } from 'node:url';
|
||||||
|
import { info, warn } from '@zen/core/shared/logger';
|
||||||
|
import { registerModule } from './registry.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sources de modules `@zen/module-*` activés dans le projet consommateur.
|
||||||
|
*
|
||||||
|
* Le projet consommateur fournit un manifeste statique (généré par
|
||||||
|
* `npx zen-modules sync`) et le passe à `initializeZen({ modules })`. Le
|
||||||
|
* manifeste a la forme :
|
||||||
|
*
|
||||||
|
* import * as posts from '@zen/module-posts';
|
||||||
|
* export const modules = [{ name: '@zen/module-posts', exports: posts }];
|
||||||
|
* await Promise.all(modules.map(m => m.exports.register?.()));
|
||||||
|
*
|
||||||
|
* Le `import *` rend l'arbre d'imports du module visible aux deux bundles
|
||||||
|
* Next.js (server + client) ; Turbopack/Webpack le bundlent comme n'importe
|
||||||
|
* quel autre fichier source. C'est ce qui permet au module de référencer du
|
||||||
|
* JSX, `next/headers`, `next/navigation`, etc. — chaque côté reçoit la bonne
|
||||||
|
* condition.
|
||||||
|
*
|
||||||
|
* Cette fonction ne fait QUE peupler le registre interne du core (pour que
|
||||||
|
* `getRegisteredModules()` retourne les bons objets côté serveur). Le top-level
|
||||||
|
* await dans le manifeste a déjà appelé `register()` au moment de son import.
|
||||||
|
*/
|
||||||
|
export function registerModules(modules) {
|
||||||
|
if (!Array.isArray(modules)) return;
|
||||||
|
|
||||||
|
for (const entry of modules) {
|
||||||
|
const ex = entry?.exports;
|
||||||
|
const name = entry?.name ?? ex?.manifest?.name ?? '<unknown>';
|
||||||
|
|
||||||
|
if (!ex?.manifest || typeof ex.register !== 'function') {
|
||||||
|
warn(`zen-modules: "${name}" missing manifest/register — skipping`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
registerModule({
|
||||||
|
manifest: ex.manifest,
|
||||||
|
register: ex.register,
|
||||||
|
createTables: ex.createTables,
|
||||||
|
dropTables: ex.dropTables,
|
||||||
|
});
|
||||||
|
info(`zen-modules: registered ${ex.manifest.name}@${ex.manifest.version ?? '?'}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers de scan (utilisés par le CLI `zen-modules sync` et l'env validation).
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const NAME_PREFIX = /^(@zen\/module-|zen-module-)/;
|
||||||
|
|
||||||
|
export function isCandidateName(name) {
|
||||||
|
return NAME_PREFIX.test(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readJson(path) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(await readFile(path, 'utf-8'));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Résout le chemin du `package.json` d'un module installé. Ne dépend PAS de
|
||||||
|
* `require.resolve(name)` (qui peut échouer si la sous-entrée `./package.json`
|
||||||
|
* n'est pas exposée par `exports`) ni de `import.meta.resolve` (variable selon
|
||||||
|
* la version Node). On remonte simplement depuis `projectCwd` en cherchant
|
||||||
|
* `node_modules/<name>/package.json` à chaque niveau — c'est le mécanisme de
|
||||||
|
* résolution npm standard.
|
||||||
|
*/
|
||||||
|
function resolveModulePackageJson(name, projectCwd) {
|
||||||
|
let dir = projectCwd;
|
||||||
|
while (true) {
|
||||||
|
const candidate = join(dir, 'node_modules', name, 'package.json');
|
||||||
|
try {
|
||||||
|
const pkg = JSON.parse(readFileSync(candidate, 'utf-8'));
|
||||||
|
return { path: candidate, pkg };
|
||||||
|
} catch {
|
||||||
|
// pas trouvé à ce niveau, remonter
|
||||||
|
}
|
||||||
|
const parent = dirname(dir);
|
||||||
|
if (parent === dir) return null;
|
||||||
|
dir = parent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function isThirdPartyModule(name, projectCwd) {
|
||||||
|
const found = resolveModulePackageJson(name, projectCwd);
|
||||||
|
return Array.isArray(found?.pkg?.keywords) && found.pkg.keywords.includes('zen-module');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vérifie si un module installé expose une sous-entrée `./client` dans son
|
||||||
|
* `package.json#exports`. Utilisé par le CLI `zen-modules sync` pour décider
|
||||||
|
* si le manifeste client doit l'importer. Les modules sans partie cliente
|
||||||
|
* (modules purement back-end / API / DB) ne sont pas inclus dans le manifeste
|
||||||
|
* client — ça évite d'embarquer leur graphe d'imports serveur dans le bundle
|
||||||
|
* browser.
|
||||||
|
*/
|
||||||
|
export async function moduleHasClientEntry(name, projectCwd) {
|
||||||
|
const found = resolveModulePackageJson(name, projectCwd);
|
||||||
|
return Boolean(found?.pkg?.exports?.['./client']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scanne `package.json` du projet consommateur et retourne la liste des noms
|
||||||
|
* de packages `@zen/module-*` (ou compatibles). N'effectue AUCUN import — le
|
||||||
|
* CLI `zen-modules sync` consomme cette liste pour générer le manifeste
|
||||||
|
* statique.
|
||||||
|
*/
|
||||||
|
export async function findInstalledModuleNames({ cwd = process.cwd() } = {}) {
|
||||||
|
const pkg = await readJson(resolve(cwd, 'package.json'));
|
||||||
|
if (!pkg) return [];
|
||||||
|
|
||||||
|
const allDeps = {
|
||||||
|
...(pkg.dependencies ?? {}),
|
||||||
|
...(pkg.devDependencies ?? {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const out = [];
|
||||||
|
for (const name of Object.keys(allDeps)) {
|
||||||
|
if (isCandidateName(name) || (await isThirdPartyModule(name, cwd))) {
|
||||||
|
out.push(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out.sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Résout l'URL ESM du main entry d'un module à partir de son `package.json`.
|
||||||
|
* On lit `exports['.'].import` puis `main`, sinon `./index.js` par défaut.
|
||||||
|
* Pas de `require.resolve` : un module `@zen/module-*` peut déclarer uniquement
|
||||||
|
* la condition `"import"` (ESM-only) — la résolution CJS échouerait alors avec
|
||||||
|
* "No exports main defined". Comme on a déjà localisé le `package.json` via
|
||||||
|
* `resolveModulePackageJson`, on construit l'URL nous-mêmes.
|
||||||
|
*/
|
||||||
|
function resolveModuleEntryUrl(found) {
|
||||||
|
const pkgDir = dirname(found.path);
|
||||||
|
const main = found.pkg?.exports?.['.']?.import ?? found.pkg?.main ?? './index.js';
|
||||||
|
return pathToFileURL(resolve(pkgDir, main)).href;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Variante "Node-only" du chargement de modules — utilisée par le CLI
|
||||||
|
* `zen-db init` qui ne passe jamais par un bundler. Charge dynamiquement
|
||||||
|
* chaque module depuis `node_modules/` du projet, pour que le CLI ait accès
|
||||||
|
* à `manifest`, `createTables`, `dropTables`. Ne déclenche PAS `register()`
|
||||||
|
* (la chaîne register-server tirerait des imports Next.js incompatibles avec
|
||||||
|
* le contexte CLI).
|
||||||
|
*
|
||||||
|
* À ne PAS utiliser depuis le runtime Next.js — utiliser le manifeste statique
|
||||||
|
* via `initializeZen({ modules })`.
|
||||||
|
*/
|
||||||
|
export async function loadModulesForCli({ cwd = process.cwd() } = {}) {
|
||||||
|
const names = await findInstalledModuleNames({ cwd });
|
||||||
|
|
||||||
|
for (const name of names) {
|
||||||
|
const found = resolveModulePackageJson(name, cwd);
|
||||||
|
if (!found) {
|
||||||
|
warn(`zen-modules: cannot find package "${name}" in node_modules`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mod;
|
||||||
|
try {
|
||||||
|
mod = await import(resolveModuleEntryUrl(found));
|
||||||
|
} catch (err) {
|
||||||
|
warn(`zen-modules: failed to import "${name}" — ${err.message}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mod.manifest || typeof mod.register !== 'function') {
|
||||||
|
warn(`zen-modules: "${name}" missing manifest/register — skipping`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
registerModule({
|
||||||
|
manifest: mod.manifest,
|
||||||
|
register: mod.register,
|
||||||
|
createTables: mod.createTables,
|
||||||
|
dropTables: mod.dropTables,
|
||||||
|
});
|
||||||
|
info(`zen-modules: loaded ${mod.manifest.name}@${mod.manifest.version ?? '?'}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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,2 @@
|
|||||||
|
export { registerModule, getRegisteredModules, getRegisteredModule, clearRegisteredModules } from './registry.js';
|
||||||
|
export { registerModules, findInstalledModuleNames, moduleHasClientEntry, loadModulesForCli, validateModuleEnvVars } from './discover.server.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()];
|
||||||
|
}
|
||||||
@@ -15,7 +15,6 @@
|
|||||||
* registerFeatureRoutes in core/api/runtime.js.
|
* registerFeatureRoutes in core/api/runtime.js.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { cookies } from 'next/headers';
|
|
||||||
import { getSessionCookieName } from '@zen/core/shared/config';
|
import { getSessionCookieName } from '@zen/core/shared/config';
|
||||||
import { getSessionResolver } from '../api/router.js';
|
import { getSessionResolver } from '../api/router.js';
|
||||||
import { getFile } from './index.js';
|
import { getFile } from './index.js';
|
||||||
@@ -60,6 +59,7 @@ async function handleGetFile(_request, { wildcard: fileKey }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Require authentication for all other paths.
|
// Require authentication for all other paths.
|
||||||
|
const { cookies } = await import('next/headers');
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
const sessionToken = cookieStore.get(COOKIE_NAME)?.value;
|
const sessionToken = cookieStore.get(COOKIE_NAME)?.value;
|
||||||
|
|
||||||
|
|||||||
@@ -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 { hashPassword, verifyPassword, generateId } from './password.js';
|
||||||
import { createSession } from './session.js';
|
import { createSession } from './session.js';
|
||||||
import { fail } from '@zen/core/shared/logger';
|
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 } = {}) {
|
async function register(userData, { onEmailVerification } = {}) {
|
||||||
const { email, password, name } = userData;
|
const { email, password, name } = userData;
|
||||||
@@ -228,4 +228,68 @@ async function updateUser(userId, updateData) {
|
|||||||
return await updateById('zen_auth_users', userId, filteredData);
|
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 };
|
||||||
|
|||||||
@@ -5,40 +5,18 @@
|
|||||||
|
|
||||||
export const PERMISSIONS = {
|
export const PERMISSIONS = {
|
||||||
ADMIN_ACCESS: 'admin.access',
|
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_VIEW: 'users.view',
|
||||||
USERS_EDIT: 'users.edit',
|
USERS_MANAGE: 'users.manage',
|
||||||
USERS_DELETE: 'users.delete',
|
|
||||||
ROLES_VIEW: 'roles.view',
|
ROLES_VIEW: 'roles.view',
|
||||||
ROLES_MANAGE: 'roles.manage',
|
ROLES_MANAGE: 'roles.manage',
|
||||||
SETTINGS_VIEW: 'settings.view',
|
|
||||||
SETTINGS_MANAGE: 'settings.manage',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const PERMISSION_DEFINITIONS = [
|
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: '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.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.manage', name: 'Gérer les utilisateurs', description: 'Permet de créer, modifier et supprimer des comptes 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.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: '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' },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+34
-9
@@ -2,8 +2,9 @@ import { query, tableExists } from '@zen/core/database';
|
|||||||
import { generateId } from './password.js';
|
import { generateId } from './password.js';
|
||||||
import { done, warn } from '@zen/core/shared/logger';
|
import { done, warn } from '@zen/core/shared/logger';
|
||||||
import { PERMISSION_DEFINITIONS } from './constants.js';
|
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 = [
|
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() {
|
async function seedDefaultRolesAndPermissions() {
|
||||||
// Permissions
|
// S'assure que les permissions core sont dans le registre, puis seed depuis
|
||||||
for (const perm of PERMISSION_DEFINITIONS) {
|
// le registre — qui contient core + permissions enregistrées par les modules.
|
||||||
|
registerPermissions(PERMISSION_DEFINITIONS);
|
||||||
|
const allPermissions = getRegisteredPermissions();
|
||||||
|
|
||||||
|
for (const perm of allPermissions) {
|
||||||
await query(
|
await query(
|
||||||
`INSERT INTO zen_auth_permissions (key, name, group_name) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING`,
|
`INSERT INTO zen_auth_permissions (key, name, description, group_name)
|
||||||
[perm.key, perm.name, perm.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
|
// Admin role
|
||||||
const adminRoleId = generateId();
|
const adminRoleId = generateId();
|
||||||
await query(
|
await query(
|
||||||
@@ -84,12 +107,14 @@ async function seedDefaultRolesAndPermissions() {
|
|||||||
const adminRole = await query(`SELECT id FROM zen_auth_roles WHERE name = 'admin'`);
|
const adminRole = await query(`SELECT id FROM zen_auth_roles WHERE name = 'admin'`);
|
||||||
const adminId = adminRole.rows[0].id;
|
const adminId = adminRole.rows[0].id;
|
||||||
|
|
||||||
for (const perm of PERMISSION_DEFINITIONS) {
|
// 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(
|
await query(
|
||||||
`INSERT INTO zen_auth_role_permissions (role_id, permission_key) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
|
`INSERT INTO zen_auth_role_permissions (role_id, permission_key)
|
||||||
[adminId, perm.key]
|
SELECT $1, key FROM zen_auth_permissions
|
||||||
|
ON CONFLICT DO NOTHING`,
|
||||||
|
[adminId]
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
// User role
|
// User role
|
||||||
const userRoleId = generateId();
|
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 { createEmailVerification, verifyEmailToken, createPasswordReset, verifyResetToken, deleteResetToken } from './verifications.js';
|
||||||
export { hashPassword, verifyPassword, generateToken, generateId } from './password.js';
|
export { hashPassword, verifyPassword, generateToken, generateId } from './password.js';
|
||||||
export { listRoles, getRoleById, createRole, updateRole, deleteRole, getUserRoles, assignUserRole, revokeUserRole } from './roles.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';
|
import { query } from '@zen/core/database';
|
||||||
export { PERMISSIONS, PERMISSION_DEFINITIONS, getPermissionGroups } from './constants.js';
|
export { PERMISSIONS, PERMISSION_DEFINITIONS, getPermissionGroups } from './constants.js';
|
||||||
|
export {
|
||||||
|
registerPermission,
|
||||||
|
registerPermissions,
|
||||||
|
getRegisteredPermissions,
|
||||||
|
getRegisteredPermissionKeys,
|
||||||
|
} from './permissions-registry.js';
|
||||||
|
|
||||||
export async function hasPermission(userId, permissionKey) {
|
export async function hasPermission(userId, permissionKey) {
|
||||||
const result = await query(
|
const result = await query(
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { query, transaction } from '@zen/core/database';
|
import { query, transaction } from '@zen/core/database';
|
||||||
import { generateId } from './password.js';
|
import { generateId } from './password.js';
|
||||||
import { PERMISSIONS } from './permissions.js';
|
import { getRegisteredPermissionKeys } from './permissions-registry.js';
|
||||||
|
|
||||||
const VALID_PERMISSION_KEYS = new Set(Object.values(PERMISSIONS));
|
|
||||||
|
|
||||||
export async function listRoles() {
|
export async function listRoles() {
|
||||||
const result = await query(
|
const result = await query(
|
||||||
@@ -60,8 +58,7 @@ export async function updateRole(roleId, { name, description, color, permissionK
|
|||||||
const values = [];
|
const values = [];
|
||||||
let idx = 1;
|
let idx = 1;
|
||||||
|
|
||||||
// System roles cannot be renamed
|
if (name !== undefined) {
|
||||||
if (!isSystem && name !== undefined) {
|
|
||||||
if (!name.trim()) throw new Error('Role name cannot be empty');
|
if (!name.trim()) throw new Error('Role name cannot be empty');
|
||||||
updateFields.push(`name = $${idx++}`);
|
updateFields.push(`name = $${idx++}`);
|
||||||
values.push(name.trim());
|
values.push(name.trim());
|
||||||
@@ -83,8 +80,9 @@ export async function updateRole(roleId, { name, description, color, permissionK
|
|||||||
values
|
values
|
||||||
);
|
);
|
||||||
|
|
||||||
if (permissionKeys !== undefined) {
|
if (!isSystem && permissionKeys !== undefined) {
|
||||||
const safeKeys = [...new Set(permissionKeys)].filter(k => VALID_PERMISSION_KEYS.has(k));
|
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]);
|
await client.query(`DELETE FROM zen_auth_role_permissions WHERE role_id = $1`, [roleId]);
|
||||||
for (const key of safeKeys) {
|
for (const key of safeKeys) {
|
||||||
await client.query(
|
await client.query(
|
||||||
|
|||||||
@@ -98,4 +98,52 @@ function deleteResetToken(email) {
|
|||||||
return deleteWhere('zen_auth_verifications', { identifier: 'password_reset', value: 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 { buildNavigationSections, buildBottomNavItems } from './navigation.js';
|
||||||
import { logoutAction } from '@zen/core/features/auth/actions';
|
import { logoutAction } from '@zen/core/features/auth/actions';
|
||||||
import { getAppName } from '@zen/core';
|
import { getAppName } from '@zen/core';
|
||||||
|
import { getUserPermissions } from '@zen/core/users';
|
||||||
import './widgets/index.server.js';
|
import './widgets/index.server.js';
|
||||||
|
|
||||||
export default async function AdminLayout({ children }) {
|
export default async function AdminLayout({ children }) {
|
||||||
const session = await protectAdmin();
|
const session = await protectAdmin();
|
||||||
const appName = getAppName();
|
const appName = getAppName();
|
||||||
const navigationSections = buildNavigationSections('/');
|
const permissions = await getUserPermissions(session.user.id);
|
||||||
|
const navigationSections = buildNavigationSections('/', permissions);
|
||||||
const bottomNavItems = buildBottomNavItems('/');
|
const bottomNavItems = buildBottomNavItems('/');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -3,18 +3,23 @@ import { protectAdmin } from './protect.js';
|
|||||||
import { collectWidgetData } from './registry.js';
|
import { collectWidgetData } from './registry.js';
|
||||||
import { getAppConfig, getPublicBaseUrl } from '@zen/core';
|
import { getAppConfig, getPublicBaseUrl } from '@zen/core';
|
||||||
import { isDevkitEnabled } from '../../shared/lib/appConfig.js';
|
import { isDevkitEnabled } from '../../shared/lib/appConfig.js';
|
||||||
|
import { getUserPermissions } from '@zen/core/users';
|
||||||
|
|
||||||
export default async function AdminPage({ params }) {
|
export default async function AdminPage({ params }) {
|
||||||
const resolvedParams = await params;
|
const resolvedParams = await params;
|
||||||
const session = await protectAdmin();
|
const session = await protectAdmin();
|
||||||
const widgetData = await collectWidgetData();
|
const [widgetData, permissions] = await Promise.all([
|
||||||
|
collectWidgetData(),
|
||||||
|
getUserPermissions(session.user.id),
|
||||||
|
]);
|
||||||
const appConfig = { ...getAppConfig(), siteUrl: getPublicBaseUrl() };
|
const appConfig = { ...getAppConfig(), siteUrl: getPublicBaseUrl() };
|
||||||
const devkitEnabled = isDevkitEnabled();
|
const devkitEnabled = isDevkitEnabled();
|
||||||
|
const user = { ...session.user, permissions };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AdminPageClient
|
<AdminPageClient
|
||||||
params={resolvedParams}
|
params={resolvedParams}
|
||||||
user={session.user}
|
user={user}
|
||||||
widgetData={widgetData}
|
widgetData={widgetData}
|
||||||
appConfig={appConfig}
|
appConfig={appConfig}
|
||||||
devkitEnabled={devkitEnabled}
|
devkitEnabled={devkitEnabled}
|
||||||
|
|||||||
@@ -0,0 +1,304 @@
|
|||||||
|
# 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 buildNavigationSections, registre (Next.js-free)
|
||||||
|
├── 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
|
||||||
|
// Gardes RSC — chemin dédié (next/navigation + next/headers, non compatible turbopackIgnore)
|
||||||
|
import { protectAdmin, isAdmin } from '@zen/core/features/admin/protect';
|
||||||
|
|
||||||
|
// Registre et navigation — compatible modules externes
|
||||||
|
import { 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 Link from 'next/link';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
import * as Icons from '@zen/core/shared/icons';
|
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
|
* 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" />
|
<Icon className="h-[15px] w-[15px] flex-shrink-0" />
|
||||||
<span>{section.title}</span>
|
<span>{section.title}</span>
|
||||||
</div>
|
</div>
|
||||||
<ChevronDownIcon
|
<ArrowDown01Icon
|
||||||
className={`h-3 w-3 transition-transform duration-[120ms] ease-out ${
|
className={`h-3 w-3 transition-transform duration-[120ms] ease-out ${
|
||||||
isCollapsed ? '-rotate-90' : 'rotate-0'
|
isCollapsed ? '-rotate-90' : 'rotate-0'
|
||||||
}`}
|
}`}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { Fragment, useState, useEffect } from 'react';
|
import { Fragment, useState, useEffect } from 'react';
|
||||||
import { Menu, MenuButton, MenuItem, MenuItems, Transition } from '@headlessui/react';
|
import { Menu, MenuButton, MenuItem, MenuItems, Transition } from '@headlessui/react';
|
||||||
import { ChevronDownIcon, User03Icon, DashboardSquare03Icon } 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 { UserAvatar } from '@zen/core/shared/components';
|
||||||
import { useRouter, usePathname } from 'next/navigation';
|
import { useRouter, usePathname } from 'next/navigation';
|
||||||
import { getPage, getPages } from '../registry.js';
|
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 crumbs = [{ icon: DashboardSquare03Icon, href: '/admin/dashboard' }];
|
||||||
const after = pathname.replace(/^\/admin\/?/, '');
|
const after = pathname.replace(/^\/admin\/?/, '');
|
||||||
const segments = after.split('/').filter(Boolean);
|
const segments = after.split('/').filter(Boolean);
|
||||||
const [first, second] = segments;
|
|
||||||
|
|
||||||
if (!after || !segments.length || (segments[0] === 'dashboard' && segments.length === 1)) {
|
if (!after || !segments.length || (segments[0] === 'dashboard' && segments.length === 1)) {
|
||||||
crumbs.push({ label: pageTitle });
|
crumbs.push({ label: pageTitle });
|
||||||
@@ -55,8 +54,15 @@ const AdminTop = ({ isMobileMenuOpen, setIsMobileMenuOpen, user, onLogout, appNa
|
|||||||
}
|
}
|
||||||
|
|
||||||
const allItems = navigationSections.flatMap(s => s.items);
|
const allItems = navigationSections.flatMap(s => s.items);
|
||||||
const navItem = allItems.find(item => item.href.replace('/admin/', '').split('/')[0] === first);
|
const navItem = allItems.find(item => {
|
||||||
const hasSubPage = segments.length > 1;
|
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) {
|
if (navItem) {
|
||||||
crumbs.push({ label: navItem.name, href: hasSubPage ? navItem.href : undefined });
|
crumbs.push({ label: navItem.name, href: hasSubPage ? navItem.href : undefined });
|
||||||
@@ -65,10 +71,11 @@ const AdminTop = ({ isMobileMenuOpen, setIsMobileMenuOpen, user, onLogout, appNa
|
|||||||
return crumbs;
|
return crumbs;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (second === 'new') {
|
const subSegment = segments[itemSegCount];
|
||||||
|
if (subSegment === 'new') {
|
||||||
crumbs.push({ label: 'Nouveau' });
|
crumbs.push({ label: 'Nouveau' });
|
||||||
} else if (second === 'edit') {
|
} else if (subSegment === 'edit') {
|
||||||
const page = getPages().find(p => p.slug === `${first}:edit`);
|
const page = getPages().find(p => p.slug === `${segments[0]}:edit`);
|
||||||
crumbs.push({ label: page?.breadcrumbLabel || page?.title || 'Modifier' });
|
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"
|
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"
|
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">
|
<Menu01Icon className="h-5 w-5 transition-transform duration-200" />
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={isMobileMenuOpen ? "M6 18L18 6M6 6l12 12" : "M4 6h16M4 12h16M4 18h16"} />
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
<h1 className="text-neutral-900 dark:text-white font-semibold text-sm">{appName}</h1>
|
<h1 className="text-neutral-900 dark:text-white font-semibold text-sm">{appName}</h1>
|
||||||
</div>
|
</div>
|
||||||
@@ -99,9 +104,7 @@ const AdminTop = ({ isMobileMenuOpen, setIsMobileMenuOpen, user, onLogout, appNa
|
|||||||
{breadcrumbs.length > 0 && breadcrumbs.map((crumb, i) => (
|
{breadcrumbs.length > 0 && breadcrumbs.map((crumb, i) => (
|
||||||
<Fragment key={i}>
|
<Fragment key={i}>
|
||||||
{i > 0 && (
|
{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">
|
<ArrowRight01Icon className="w-3 h-3 text-neutral-400 dark:text-neutral-600 flex-shrink-0" />
|
||||||
<polyline points="9 18 15 12 9 6" />
|
|
||||||
</svg>
|
|
||||||
)}
|
)}
|
||||||
{crumb.icon ? (
|
{crumb.icon ? (
|
||||||
<button
|
<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">
|
<span className="hidden sm:block text-[13px] leading-none font-medium text-neutral-800 dark:text-white">
|
||||||
{user?.name || 'User'}
|
{user?.name || 'User'}
|
||||||
</span>
|
</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>
|
</MenuButton>
|
||||||
|
|
||||||
<Transition
|
<Transition
|
||||||
@@ -176,9 +179,7 @@ const AdminTop = ({ isMobileMenuOpen, setIsMobileMenuOpen, user, onLogout, appNa
|
|||||||
onClick={handleLogout}
|
onClick={handleLogout}
|
||||||
className="cursor-pointer w-full flex items-center gap-2 px-[7px] py-[10px] rounded-lg text-[13px] leading-none text-red-700 dark:text-red-600 transition-colors duration-150 text-left data-focus:bg-red-700/10 dark:data-focus:bg-red-700/20"
|
className="cursor-pointer w-full flex items-center gap-2 px-[7px] py-[10px] rounded-lg text-[13px] leading-none text-red-700 dark:text-red-600 transition-colors duration-150 text-left data-focus:bg-red-700/10 dark:data-focus:bg-red-700/20"
|
||||||
>
|
>
|
||||||
<svg className="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<Logout02Icon className="w-4 h-4 shrink-0" />
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
|
||||||
</svg>
|
|
||||||
Se déconnecter
|
Se déconnecter
|
||||||
</button>
|
</button>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
|
|||||||
@@ -3,9 +3,6 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Input, Textarea, Switch, Modal, ColorPicker } from '@zen/core/shared/components';
|
import { Input, Textarea, Switch, Modal, ColorPicker } from '@zen/core/shared/components';
|
||||||
import { useToast } from '@zen/core/toast';
|
import { useToast } from '@zen/core/toast';
|
||||||
import { getPermissionGroups } from '@zen/core/users/constants';
|
|
||||||
|
|
||||||
const PERMISSION_GROUPS = getPermissionGroups();
|
|
||||||
|
|
||||||
const RoleEditModal = ({ roleId, isOpen, onClose, onSaved }) => {
|
const RoleEditModal = ({ roleId, isOpen, onClose, onSaved }) => {
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
@@ -19,9 +16,12 @@ const RoleEditModal = ({ roleId, isOpen, onClose, onSaved }) => {
|
|||||||
const [description, setDescription] = useState('');
|
const [description, setDescription] = useState('');
|
||||||
const [color, setColor] = useState('#6b7280');
|
const [color, setColor] = useState('#6b7280');
|
||||||
const [selectedPerms, setSelectedPerms] = useState([]);
|
const [selectedPerms, setSelectedPerms] = useState([]);
|
||||||
|
// Catalogue dynamique des permissions (core + modules), récupéré via l'API.
|
||||||
|
const [permissionGroups, setPermissionGroups] = useState({});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) return;
|
if (!isOpen) return;
|
||||||
|
fetchPermissions();
|
||||||
if (isNew) {
|
if (isNew) {
|
||||||
setName('');
|
setName('');
|
||||||
setDescription('');
|
setDescription('');
|
||||||
@@ -33,6 +33,18 @@ const RoleEditModal = ({ roleId, isOpen, onClose, onSaved }) => {
|
|||||||
fetchRole();
|
fetchRole();
|
||||||
}, [isOpen, roleId]);
|
}, [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 () => {
|
const fetchRole = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -125,7 +137,6 @@ const RoleEditModal = ({ roleId, isOpen, onClose, onSaved }) => {
|
|||||||
label="Nom du rôle"
|
label="Nom du rôle"
|
||||||
value={name}
|
value={name}
|
||||||
onChange={setName}
|
onChange={setName}
|
||||||
disabled={isSystem}
|
|
||||||
placeholder="Éditeur, Modérateur..."
|
placeholder="Éditeur, Modérateur..."
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
@@ -147,7 +158,7 @@ const RoleEditModal = ({ roleId, isOpen, onClose, onSaved }) => {
|
|||||||
|
|
||||||
<div className="flex flex-col gap-3">
|
<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>
|
<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 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">
|
<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">
|
<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)}
|
onChange={() => togglePerm(perm.key)}
|
||||||
label={perm.name}
|
label={perm.name}
|
||||||
description={perm.description}
|
description={perm.description}
|
||||||
|
disabled={isSystem}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Input, TagInput, Modal, Badge } 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) => (
|
||||||
|
<Badge key={opt.value} color={opt.color} dot onRemove={onRemove}>{opt.label}</Badge>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<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;
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Input, TagInput, Modal, RoleBadge } from '@zen/core/shared/components';
|
import { Input, TagInput, Modal, Badge } from '@zen/core/shared/components';
|
||||||
import { useToast } from '@zen/core/toast';
|
import { useToast } from '@zen/core/toast';
|
||||||
|
|
||||||
const UserEditModal = ({ userId, currentUserId, isOpen, onClose, onSaved }) => {
|
const UserEditModal = ({ userId, currentUserId, isOpen, onClose, onSaved }) => {
|
||||||
@@ -123,22 +123,33 @@ const UserEditModal = ({ userId, currentUserId, isOpen, onClose, onSaved }) => {
|
|||||||
const toAdd = selectedRoleIds.filter(id => !initialRoleIds.includes(id));
|
const toAdd = selectedRoleIds.filter(id => !initialRoleIds.includes(id));
|
||||||
const toRemove = initialRoleIds.filter(id => !selectedRoleIds.includes(id));
|
const toRemove = initialRoleIds.filter(id => !selectedRoleIds.includes(id));
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.all(
|
||||||
...toAdd.map(roleId =>
|
toAdd.map(roleId =>
|
||||||
fetch(`/zen/api/users/${userId}/roles`, {
|
fetch(`/zen/api/users/${userId}/roles`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
body: JSON.stringify({ roleId }),
|
body: JSON.stringify({ roleId }),
|
||||||
})
|
})
|
||||||
),
|
)
|
||||||
...toRemove.map(roleId =>
|
);
|
||||||
|
|
||||||
|
const removeResults = await Promise.all(
|
||||||
|
toRemove.map(roleId =>
|
||||||
fetch(`/zen/api/users/${userId}/roles/${roleId}`, {
|
fetch(`/zen/api/users/${userId}/roles/${roleId}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
credentials: 'include',
|
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 (emailChanged) {
|
||||||
if (isSelf) {
|
if (isSelf) {
|
||||||
@@ -258,7 +269,7 @@ const UserEditModal = ({ userId, currentUserId, isOpen, onClose, onSaved }) => {
|
|||||||
onChange={setSelectedRoleIds}
|
onChange={setSelectedRoleIds}
|
||||||
placeholder="Rechercher un rôle..."
|
placeholder="Rechercher un rôle..."
|
||||||
renderTag={(opt, onRemove) => (
|
renderTag={(opt, onRemove) => (
|
||||||
<RoleBadge key={opt.value} name={opt.label} color={opt.color} onRemove={onRemove} />
|
<Badge key={opt.value} color={opt.color} dot onRemove={onRemove}>{opt.label}</Badge>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -7,3 +7,4 @@ export { default as AdminHeader } from './AdminHeader.js';
|
|||||||
export { default as ThemeToggle } from './ThemeToggle.js';
|
export { default as ThemeToggle } from './ThemeToggle.js';
|
||||||
export { default as UserEditModal } from './UserEditModal.client.js';
|
export { default as UserEditModal } from './UserEditModal.client.js';
|
||||||
export { default as RoleEditModal } from './RoleEditModal.client.js';
|
export { default as RoleEditModal } from './RoleEditModal.client.js';
|
||||||
|
export { default as UserCreateModal } from './UserCreateModal.client.js';
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
@@ -9,12 +10,38 @@ import {
|
|||||||
Select,
|
Select,
|
||||||
Textarea,
|
Textarea,
|
||||||
Switch,
|
Switch,
|
||||||
|
TagInput,
|
||||||
StatCard,
|
StatCard,
|
||||||
Loading,
|
Loading,
|
||||||
|
BlockEditor,
|
||||||
} from '@zen/core/shared/components';
|
} from '@zen/core/shared/components';
|
||||||
import { UserCircle02Icon } from '@zen/core/shared/icons';
|
import { UserCircle02Icon } from '@zen/core/shared/icons';
|
||||||
import AdminHeader from '../components/AdminHeader.js';
|
import AdminHeader from '../components/AdminHeader.js';
|
||||||
|
|
||||||
|
const ROLE_OPTIONS = [
|
||||||
|
{ value: 'admin', label: 'Admin', color: '#6366f1' },
|
||||||
|
{ value: 'editor', label: 'Éditeur', color: '#f59e0b' },
|
||||||
|
{ value: 'viewer', label: 'Lecteur', color: '#10b981' },
|
||||||
|
{ value: 'support', label: 'Support', color: '#3b82f6' },
|
||||||
|
{ value: 'billing', label: 'Facturation', color: '#ec4899' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function TagInputDemo({ label, description, error, renderTag }) {
|
||||||
|
const [value, setValue] = useState([]);
|
||||||
|
return (
|
||||||
|
<TagInput
|
||||||
|
label={label}
|
||||||
|
description={description}
|
||||||
|
error={error}
|
||||||
|
placeholder="Ajouter un rôle..."
|
||||||
|
options={ROLE_OPTIONS}
|
||||||
|
value={value}
|
||||||
|
onChange={setValue}
|
||||||
|
renderTag={renderTag}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function PreviewBlock({ title, children }) {
|
function PreviewBlock({ title, children }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
@@ -77,7 +104,7 @@ export default function ComponentsPage() {
|
|||||||
<PreviewBlock title="Card — variants">
|
<PreviewBlock title="Card — variants">
|
||||||
{['default', 'elevated', 'outline', 'success', 'info', 'warning', 'danger'].map(v => (
|
{['default', 'elevated', 'outline', 'success', 'info', 'warning', 'danger'].map(v => (
|
||||||
<Card key={v} variant={v} padding="md" className="min-w-[140px]">
|
<Card key={v} variant={v} padding="md" className="min-w-[140px]">
|
||||||
<span className="text-sm font-medium">{v}</span>
|
<span className="text-sm font-medium text-black dark:text-white">{v}</span>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</PreviewBlock>
|
</PreviewBlock>
|
||||||
@@ -165,9 +192,47 @@ export default function ComponentsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</PreviewBlock>
|
</PreviewBlock>
|
||||||
|
|
||||||
|
<PreviewBlock title="TagInput">
|
||||||
|
<div className="w-72 flex flex-col gap-3">
|
||||||
|
<TagInputDemo label="Rôles" description="Sélectionnez un ou plusieurs rôles." />
|
||||||
|
<TagInputDemo
|
||||||
|
label="Avec pastilles colorées"
|
||||||
|
renderTag={(opt, onRemove) => (
|
||||||
|
<Badge key={opt.value} color={opt.color} dot onRemove={onRemove}>{opt.label}</Badge>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<TagInputDemo label="Avec erreur" error="Ce champ est requis." />
|
||||||
|
</div>
|
||||||
|
</PreviewBlock>
|
||||||
|
|
||||||
<PreviewBlock title="Loading">
|
<PreviewBlock title="Loading">
|
||||||
<Loading />
|
<Loading />
|
||||||
</PreviewBlock>
|
</PreviewBlock>
|
||||||
|
|
||||||
|
<PreviewBlock title="BlockEditor">
|
||||||
|
<BlockEditorDemo />
|
||||||
|
</PreviewBlock>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BlockEditorDemo() {
|
||||||
|
const [blocks, setBlocks] = useState([
|
||||||
|
{ id: 'demo-1', type: 'heading_1', content: 'Bienvenue dans BlockEditor' },
|
||||||
|
{ id: 'demo-2', type: 'paragraph', content: "Tapez '/' pour ouvrir le menu de commandes." },
|
||||||
|
{ id: 'demo-3', type: 'bullet_item', content: 'Glissez la poignée ⋮⋮ pour réordonner' },
|
||||||
|
{ id: 'demo-4', type: 'bullet_item', content: 'Tapez `# ` au début pour un titre, `- ` pour une puce' },
|
||||||
|
{ id: 'demo-5', type: 'paragraph', content: '' },
|
||||||
|
]);
|
||||||
|
return (
|
||||||
|
<div className="w-full flex flex-col gap-4">
|
||||||
|
<BlockEditor value={blocks} onChange={setBlocks} />
|
||||||
|
<details className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||||
|
<summary className="cursor-pointer select-none">Aperçu JSON</summary>
|
||||||
|
<pre className="mt-2 p-3 rounded-lg bg-neutral-100 dark:bg-neutral-800/60 overflow-x-auto text-[11px] leading-relaxed">
|
||||||
|
{JSON.stringify(blocks, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</details>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,16 +52,16 @@ export default function IconsPage() {
|
|||||||
Aucune icône trouvée pour “{query}”
|
Aucune icône trouvée pour “{query}”
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-4 sm:grid-cols-6 lg:grid-cols-8 xl:grid-cols-10 gap-2">
|
<div className="grid grid-cols-4 sm:grid-cols-6 md:grid-cols-8 lg:grid-cols-10 xl:grid-cols-12 2xl:grid-cols-[repeat(16,minmax(0,1fr))] gap-2">
|
||||||
{filtered.map(([name, IconComponent]) => (
|
{filtered.map(([name, IconComponent]) => (
|
||||||
<button
|
<button
|
||||||
key={name}
|
key={name}
|
||||||
onClick={() => handleCopy(name)}
|
onClick={() => handleCopy(name)}
|
||||||
title={name}
|
title={name}
|
||||||
className="flex flex-col items-center gap-1.5 rounded-lg border border-transparent hover:border-neutral-200 dark:hover:border-neutral-700 hover:bg-neutral-50 dark:hover:bg-neutral-800/50 p-3 transition-colors duration-100 group cursor-pointer"
|
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-5 h-5 text-neutral-700 dark:text-neutral-300 group-hover:text-blue-700 dark:group-hover:text-blue-400 transition-colors" />
|
<IconComponent className="w-7 h-7 text-black dark:text-white" />
|
||||||
<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">
|
<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', '')}
|
{name.replace('Icon', '')}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,16 +1,20 @@
|
|||||||
/**
|
/**
|
||||||
* Zen Admin — barrel serveur.
|
* Zen Admin — barrel serveur (Next.js-free).
|
||||||
*
|
*
|
||||||
* - Gardes d'accès : protectAdmin, isAdmin.
|
|
||||||
* - Navigation : buildNavigationSections.
|
* - Navigation : buildNavigationSections.
|
||||||
* - Registre d'extensions : registerWidget, registerWidgetFetcher, registerNavItem,
|
* - Registre d'extensions : registerWidget, registerWidgetFetcher, registerNavItem,
|
||||||
* registerNavSection, registerPage (import une seule fois depuis le layout
|
* registerNavSection, registerPage (import une seule fois depuis le layout
|
||||||
* racine de l'app consommatrice pour que les side effects s'exécutent).
|
* racine de l'app consommatrice pour que les side effects s'exécutent).
|
||||||
*
|
*
|
||||||
|
* Ne re-exporte PAS protect.js — ce fichier importe `next/navigation` et
|
||||||
|
* `@zen/core/features/auth/actions` (qui importe `next/headers`) au niveau
|
||||||
|
* top-level. Ce barrel est importé par des modules externes pendant leur
|
||||||
|
* register(), avant que Next.js ait activé ses alias de modules.
|
||||||
|
* Importer les gardes explicitement via @zen/core/features/admin/protect.
|
||||||
|
*
|
||||||
* Client components sous @zen/core/features/admin/components.
|
* Client components sous @zen/core/features/admin/components.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export { protectAdmin, isAdmin } from './protect.js';
|
|
||||||
export { buildNavigationSections } from './navigation.js';
|
export { buildNavigationSections } from './navigation.js';
|
||||||
export {
|
export {
|
||||||
registerWidget,
|
registerWidget,
|
||||||
|
|||||||
@@ -5,14 +5,15 @@ import {
|
|||||||
getNavItems,
|
getNavItems,
|
||||||
} from './registry.js';
|
} from './registry.js';
|
||||||
import { isDevkitEnabled } from '../../shared/lib/appConfig.js';
|
import { isDevkitEnabled } from '../../shared/lib/appConfig.js';
|
||||||
|
import { PERMISSIONS } from '@zen/core/users/constants';
|
||||||
|
|
||||||
// Sections et items core — enregistrés à l'import de ce module.
|
// Sections et items core — enregistrés à l'import de ce module.
|
||||||
registerNavSection({ id: 'dashboard', title: 'Tableau de bord', icon: 'DashboardSquare03Icon', order: 10 });
|
registerNavSection({ id: 'dashboard', title: 'Tableau de bord', icon: 'DashboardSquare03Icon', order: 10 });
|
||||||
registerNavSection({ id: 'system', title: 'Utilisateurs', icon: 'UserMultiple02Icon', order: 20 });
|
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: '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: '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 });
|
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 });
|
registerNavItem({ id: 'settings', label: 'Paramètres', icon: 'Settings02Icon', href: '/admin/settings', position: 'bottom', order: 10 });
|
||||||
|
|
||||||
if (isDevkitEnabled()) {
|
if (isDevkitEnabled()) {
|
||||||
@@ -24,10 +25,17 @@ if (isDevkitEnabled()) {
|
|||||||
/**
|
/**
|
||||||
* Build sections for AdminSidebar. Items are sérialisables (pas de composants),
|
* Build sections for AdminSidebar. Items are sérialisables (pas de composants),
|
||||||
* icônes en chaînes résolues côté client.
|
* 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 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();
|
const bySection = new Map();
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
|
|||||||
@@ -3,9 +3,10 @@
|
|||||||
import { getWidgets, registerPage } from '../registry.js';
|
import { getWidgets, registerPage } from '../registry.js';
|
||||||
import AdminHeader from '../components/AdminHeader.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 loading = stats === null || stats === undefined;
|
||||||
const widgets = getWidgets();
|
const permissions = user?.permissions ?? [];
|
||||||
|
const widgets = getWidgets().filter(w => !w.permission || permissions.includes(w.permission));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4 sm:gap-6 lg:gap-8">
|
<div className="flex flex-col gap-4 sm:gap-6 lg:gap-8">
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { registerPage } from '../registry.js';
|
import { registerPage } from '../registry.js';
|
||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { Card, Input, Button, TabNav, UserAvatar, Modal, PasswordStrengthIndicator } from '@zen/core/shared/components';
|
import { Card, Input, Button, TabNav, UserAvatar, Modal, PasswordStrengthIndicator } from '@zen/core/shared/components';
|
||||||
|
import { SmartPhone01Icon, ComputerIcon } from '@zen/core/shared/icons';
|
||||||
import { useToast } from '@zen/core/toast';
|
import { useToast } from '@zen/core/toast';
|
||||||
import AdminHeader from '../components/AdminHeader.js';
|
import AdminHeader from '../components/AdminHeader.js';
|
||||||
|
|
||||||
@@ -403,13 +404,9 @@ const ProfilePage = ({ user: initialUser }) => {
|
|||||||
<div key={session.id} className="flex items-center justify-between gap-4 py-3 first:pt-0 last:pb-0">
|
<div key={session.id} className="flex items-center justify-between gap-4 py-3 first:pt-0 last:pb-0">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{session.device === 'mobile' ? (
|
{session.device === 'mobile' ? (
|
||||||
<svg className="w-8 h-8 text-neutral-400 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
<SmartPhone01Icon className="w-8 h-8 text-neutral-500 dark:text-neutral-400 shrink-0" />
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M10.5 1.5H8.25A2.25 2.25 0 006 3.75v16.5a2.25 2.25 0 002.25 2.25h7.5A2.25 2.25 0 0018 20.25V3.75a2.25 2.25 0 00-2.25-2.25H13.5m-3 0V3h3V1.5m-3 0h3m-3 8.25h3" />
|
|
||||||
</svg>
|
|
||||||
) : (
|
) : (
|
||||||
<svg className="w-8 h-8 text-neutral-400 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
<ComputerIcon className="w-8 h-8 text-neutral-500 dark:text-neutral-400 shrink-0" />
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 17.25v1.007a3 3 0 01-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0115 18.257V17.25m6-12V15a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 15V5.25m18 0A2.25 2.25 0 0018.75 3H5.25A2.25 2.25 0 003 5.25m18 0H3" />
|
|
||||||
</svg>
|
|
||||||
)}
|
)}
|
||||||
<div className="flex flex-col gap-0.5">
|
<div className="flex flex-col gap-0.5">
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { useToast } from '@zen/core/toast';
|
|||||||
import AdminHeader from '../components/AdminHeader.js';
|
import AdminHeader from '../components/AdminHeader.js';
|
||||||
import RoleEditModal from '../components/RoleEditModal.client.js';
|
import RoleEditModal from '../components/RoleEditModal.client.js';
|
||||||
|
|
||||||
const RolesPageClient = () => {
|
const RolesPageClient = ({ canManage }) => {
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const [roles, setRoles] = useState([]);
|
const [roles, setRoles] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
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,
|
render: (role) => role.is_system ? <Badge variant="default" size="sm">système</Badge> : null,
|
||||||
skeleton: { height: 'h-4', width: '60px' },
|
skeleton: { height: 'h-4', width: '60px' },
|
||||||
},
|
},
|
||||||
{
|
...(canManage ? [{
|
||||||
key: 'actions',
|
key: 'actions',
|
||||||
label: '',
|
label: '',
|
||||||
sortable: false,
|
sortable: false,
|
||||||
@@ -99,7 +99,7 @@ const RolesPageClient = () => {
|
|||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
skeleton: { height: 'h-8', width: '80px', className: 'rounded-lg' },
|
skeleton: { height: 'h-8', width: '80px', className: 'rounded-lg' },
|
||||||
},
|
}] : []),
|
||||||
];
|
];
|
||||||
|
|
||||||
const fetchRoles = async () => {
|
const fetchRoles = async () => {
|
||||||
@@ -161,14 +161,17 @@ const RolesPageClient = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const RolesPage = () => (
|
const RolesPage = ({ user }) => {
|
||||||
|
const canManage = user?.permissions?.includes('roles.manage');
|
||||||
|
return (
|
||||||
<div className="flex flex-col gap-4 sm:gap-6 lg:gap-8">
|
<div className="flex flex-col gap-4 sm:gap-6 lg:gap-8">
|
||||||
<RolesPageHeader />
|
<RolesPageHeader canManage={canManage} />
|
||||||
<RolesPageClient />
|
<RolesPageClient canManage={canManage} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const RolesPageHeader = () => {
|
const RolesPageHeader = ({ canManage }) => {
|
||||||
const [modalOpen, setModalOpen] = useState(false);
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -176,17 +179,19 @@ const RolesPageHeader = () => {
|
|||||||
<AdminHeader
|
<AdminHeader
|
||||||
title="Rôles"
|
title="Rôles"
|
||||||
description="Gérez les rôles et leurs permissions"
|
description="Gérez les rôles et leurs permissions"
|
||||||
action={
|
action={canManage && (
|
||||||
<Button variant="primary" onClick={() => setModalOpen(true)}>
|
<Button variant="primary" onClick={() => setModalOpen(true)}>
|
||||||
Nouveau rôle
|
Nouveau rôle
|
||||||
</Button>
|
</Button>
|
||||||
}
|
)}
|
||||||
/>
|
/>
|
||||||
|
{canManage && (
|
||||||
<RoleEditModal
|
<RoleEditModal
|
||||||
roleId="new"
|
roleId="new"
|
||||||
isOpen={modalOpen}
|
isOpen={modalOpen}
|
||||||
onClose={() => setModalOpen(false)}
|
onClose={() => setModalOpen(false)}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,13 +2,14 @@
|
|||||||
|
|
||||||
import { registerPage } from '../registry.js';
|
import { registerPage } from '../registry.js';
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Card, Table, Badge, StatusBadge, Button, UserAvatar, RelativeDate, RoleBadge } from '@zen/core/shared/components';
|
import { Card, Table, Badge, StatusBadge, Button, UserAvatar, RelativeDate } from '@zen/core/shared/components';
|
||||||
import { PencilEdit01Icon } from '@zen/core/shared/icons';
|
import { PencilEdit01Icon } from '@zen/core/shared/icons';
|
||||||
import { useToast } from '@zen/core/toast';
|
import { useToast } from '@zen/core/toast';
|
||||||
import AdminHeader from '../components/AdminHeader.js';
|
import AdminHeader from '../components/AdminHeader.js';
|
||||||
import UserEditModal from '../components/UserEditModal.client.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 toast = useToast();
|
||||||
const [users, setUsers] = useState([]);
|
const [users, setUsers] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -54,7 +55,7 @@ const UsersPageClient = ({ currentUserId }) => {
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
{visible.map(role => (
|
{visible.map(role => (
|
||||||
<RoleBadge key={role.id} name={role.name} color={role.color} />
|
<Badge key={role.id} color={role.color} dot>{role.name}</Badge>
|
||||||
))}
|
))}
|
||||||
{overflow > 0 && <Badge>+{overflow}</Badge>}
|
{overflow > 0 && <Badge>+{overflow}</Badge>}
|
||||||
{roles.length === 0 && <span className="text-xs text-neutral-400">—</span>}
|
{roles.length === 0 && <span className="text-xs text-neutral-400">—</span>}
|
||||||
@@ -77,7 +78,7 @@ const UsersPageClient = ({ currentUserId }) => {
|
|||||||
render: (user) => <RelativeDate date={user.created_at} />,
|
render: (user) => <RelativeDate date={user.created_at} />,
|
||||||
skeleton: { height: 'h-4', width: '70%' },
|
skeleton: { height: 'h-4', width: '70%' },
|
||||||
},
|
},
|
||||||
{
|
...(canEdit ? [{
|
||||||
key: 'actions',
|
key: 'actions',
|
||||||
label: '',
|
label: '',
|
||||||
sortable: false,
|
sortable: false,
|
||||||
@@ -93,7 +94,7 @@ const UsersPageClient = ({ currentUserId }) => {
|
|||||||
</Button>
|
</Button>
|
||||||
),
|
),
|
||||||
skeleton: { height: 'h-8', width: '80px', className: 'rounded-lg' },
|
skeleton: { height: 'h-8', width: '80px', className: 'rounded-lg' },
|
||||||
},
|
}] : []),
|
||||||
];
|
];
|
||||||
|
|
||||||
const fetchUsers = async () => {
|
const fetchUsers = async () => {
|
||||||
@@ -126,7 +127,7 @@ const UsersPageClient = ({ currentUserId }) => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchUsers();
|
fetchUsers();
|
||||||
}, [sortBy, sortOrder, pagination.page, pagination.limit]);
|
}, [sortBy, sortOrder, pagination.page, pagination.limit, refreshKey]);
|
||||||
|
|
||||||
const handlePageChange = (newPage) => setPagination(prev => ({ ...prev, page: newPage }));
|
const handlePageChange = (newPage) => setPagination(prev => ({ ...prev, page: newPage }));
|
||||||
const handleLimitChange = (newLimit) => setPagination(prev => ({ ...prev, limit: newLimit, page: 1 }));
|
const handleLimitChange = (newLimit) => setPagination(prev => ({ ...prev, limit: newLimit, page: 1 }));
|
||||||
@@ -157,6 +158,7 @@ const UsersPageClient = ({ currentUserId }) => {
|
|||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{canEdit && (
|
||||||
<UserEditModal
|
<UserEditModal
|
||||||
userId={editingUserId}
|
userId={editingUserId}
|
||||||
currentUserId={currentUserId}
|
currentUserId={currentUserId}
|
||||||
@@ -164,16 +166,38 @@ const UsersPageClient = ({ currentUserId }) => {
|
|||||||
onClose={() => setEditingUserId(null)}
|
onClose={() => setEditingUserId(null)}
|
||||||
onSaved={fetchUsers}
|
onSaved={fetchUsers}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const UsersPage = ({ user }) => (
|
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">
|
<div className="flex flex-col gap-4 sm:gap-6 lg:gap-8">
|
||||||
<AdminHeader title="Utilisateurs" description="Gérez les comptes utilisateurs" />
|
<AdminHeader
|
||||||
<UsersPageClient currentUserId={user?.id} />
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export default UsersPage;
|
export default UsersPage;
|
||||||
|
|
||||||
|
|||||||
@@ -7,17 +7,26 @@
|
|||||||
* - navItem : une entrée de la sidebar admin (section optionnelle pour grouper).
|
* - navItem : une entrée de la sidebar admin (section optionnelle pour grouper).
|
||||||
* - page : un composant rendu sous /admin/<slug>.
|
* - page : un composant rendu sous /admin/<slug>.
|
||||||
*
|
*
|
||||||
* Les instances de module sont séparées entre le bundle serveur et le bundle
|
* Les Maps sont stockées sur `globalThis` via `Symbol.for` pour survivre :
|
||||||
* client de Next.js ; c'est attendu : les fetchers vivent côté serveur, les
|
* 1. au hot-reload de Next.js dev (sinon les enregistrements disparaissent).
|
||||||
* Composants côté client. Les navItems et les pages sont enregistrés côté
|
* 2. à la double-instanciation du fichier — l'instrumentation hook tourne en
|
||||||
* neutre et visibles des deux côtés.
|
* Node natif (require ESM), tandis que les Server Components passent par
|
||||||
|
* le bundle Turbopack/Webpack. Sans `globalThis`, les nav items poussés
|
||||||
|
* par `register-server.js` au boot ne seraient pas visibles côté Server
|
||||||
|
* Component qui rend la sidebar — la sidebar resterait vide.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const widgetFetchers = new Map(); // id -> async () => data
|
const REGISTRY_KEY = Symbol.for('__ZEN_ADMIN_REGISTRY__');
|
||||||
const widgetComponents = new Map(); // id -> { Component, order }
|
if (!globalThis[REGISTRY_KEY]) {
|
||||||
const navItems = new Map(); // id -> { id, label, icon, href, order, sectionId }
|
globalThis[REGISTRY_KEY] = {
|
||||||
const navSections = new Map(); // id -> { id, title, icon, order }
|
widgetFetchers: new Map(), // id -> async () => data
|
||||||
const pages = new Map(); // slug -> { slug, Component, title? }
|
widgetComponents: new Map(), // id -> { Component, order, permission }
|
||||||
|
navItems: new Map(), // id -> { id, label, icon, href, order, sectionId, position, permission }
|
||||||
|
navSections: new Map(), // id -> { id, title, icon, order }
|
||||||
|
pages: new Map(), // slug -> { slug, Component, title?, breadcrumbLabel? }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const { widgetFetchers, widgetComponents, navItems, navSections, pages } = globalThis[REGISTRY_KEY];
|
||||||
|
|
||||||
// ---- Widgets ---------------------------------------------------------------
|
// ---- Widgets ---------------------------------------------------------------
|
||||||
|
|
||||||
@@ -25,8 +34,8 @@ export function registerWidgetFetcher(id, fetcher) {
|
|||||||
widgetFetchers.set(id, fetcher);
|
widgetFetchers.set(id, fetcher);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function registerWidget({ id, Component, order = 0 }) {
|
export function registerWidget({ id, Component, order = 0, permission }) {
|
||||||
widgetComponents.set(id, { Component, order });
|
widgetComponents.set(id, { Component, order, permission });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getWidgets() {
|
export function getWidgets() {
|
||||||
@@ -57,8 +66,8 @@ export function registerNavSection({ id, title, icon, order = 0 }) {
|
|||||||
navSections.set(id, { id, title, icon, order });
|
navSections.set(id, { id, title, icon, order });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function registerNavItem({ id, label, icon, href, order = 0, sectionId = 'main', position }) {
|
export function registerNavItem({ id, label, icon, href, order = 0, sectionId = 'main', position, permission }) {
|
||||||
navItems.set(id, { id, label, icon, href, order, sectionId, position });
|
navItems.set(id, { id, label, icon, href, order, sectionId, position, permission });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getNavSections() {
|
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 ResetPasswordPage from './pages/ResetPasswordPage.client.js';
|
||||||
import ConfirmEmailPage from './pages/ConfirmEmailPage.client.js';
|
import ConfirmEmailPage from './pages/ConfirmEmailPage.client.js';
|
||||||
import LogoutPage from './pages/LogoutPage.client.js';
|
import LogoutPage from './pages/LogoutPage.client.js';
|
||||||
|
import SetupAccountPage from './pages/SetupAccountPage.client.js';
|
||||||
|
|
||||||
const PAGE_COMPONENTS = {
|
const PAGE_COMPONENTS = {
|
||||||
login: LoginPage,
|
login: LoginPage,
|
||||||
@@ -16,6 +17,7 @@ const PAGE_COMPONENTS = {
|
|||||||
reset: ResetPasswordPage,
|
reset: ResetPasswordPage,
|
||||||
confirm: ConfirmEmailPage,
|
confirm: ConfirmEmailPage,
|
||||||
logout: LogoutPage,
|
logout: LogoutPage,
|
||||||
|
setup: SetupAccountPage,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function AuthPage({
|
export default function AuthPage({
|
||||||
@@ -26,6 +28,7 @@ export default function AuthPage({
|
|||||||
forgotPasswordAction,
|
forgotPasswordAction,
|
||||||
resetPasswordAction,
|
resetPasswordAction,
|
||||||
verifyEmailAction,
|
verifyEmailAction,
|
||||||
|
setupAccountAction,
|
||||||
logoutAction,
|
logoutAction,
|
||||||
setSessionCookieAction,
|
setSessionCookieAction,
|
||||||
redirectAfterLogin = '/',
|
redirectAfterLogin = '/',
|
||||||
@@ -81,6 +84,8 @@ export default function AuthPage({
|
|||||||
return <Page {...common} onSubmit={resetPasswordAction} email={email} token={token} />;
|
return <Page {...common} onSubmit={resetPasswordAction} email={email} token={token} />;
|
||||||
case ConfirmEmailPage:
|
case ConfirmEmailPage:
|
||||||
return <Page {...common} onSubmit={verifyEmailAction} email={email} token={token} />;
|
return <Page {...common} onSubmit={verifyEmailAction} email={email} token={token} />;
|
||||||
|
case SetupAccountPage:
|
||||||
|
return <Page {...common} onSubmit={setupAccountAction} email={email} token={token} />;
|
||||||
case LogoutPage:
|
case LogoutPage:
|
||||||
return <Page onLogout={logoutAction} onSetSessionCookie={setSessionCookieAction} />;
|
return <Page onLogout={logoutAction} onSetSessionCookie={setSessionCookieAction} />;
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
forgotPasswordAction,
|
forgotPasswordAction,
|
||||||
resetPasswordAction,
|
resetPasswordAction,
|
||||||
verifyEmailAction,
|
verifyEmailAction,
|
||||||
|
setupAccountAction,
|
||||||
setSessionCookie,
|
setSessionCookie,
|
||||||
getSession,
|
getSession,
|
||||||
} from './actions.js';
|
} from './actions.js';
|
||||||
@@ -14,7 +15,7 @@ export default async function AuthPage({ params, searchParams }) {
|
|||||||
const session = await getSession();
|
const session = await getSession();
|
||||||
|
|
||||||
return (
|
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">
|
<div className="max-w-md w-full">
|
||||||
<AuthPageClient
|
<AuthPageClient
|
||||||
params={params}
|
params={params}
|
||||||
@@ -25,6 +26,7 @@ export default async function AuthPage({ params, searchParams }) {
|
|||||||
forgotPasswordAction={forgotPasswordAction}
|
forgotPasswordAction={forgotPasswordAction}
|
||||||
resetPasswordAction={resetPasswordAction}
|
resetPasswordAction={resetPasswordAction}
|
||||||
verifyEmailAction={verifyEmailAction}
|
verifyEmailAction={verifyEmailAction}
|
||||||
|
setupAccountAction={setupAccountAction}
|
||||||
setSessionCookieAction={setSessionCookie}
|
setSessionCookieAction={setSessionCookie}
|
||||||
redirectAfterLogin={process.env.ZEN_AUTH_REDIRECT_AFTER_LOGIN || '/'}
|
redirectAfterLogin={process.env.ZEN_AUTH_REDIRECT_AFTER_LOGIN || '/'}
|
||||||
currentUser={session?.user || null}
|
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';
|
'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 { validateSession, deleteSession } from './session.js';
|
||||||
import { verifyEmailToken, verifyResetToken, sendVerificationEmail, sendPasswordResetEmail } from './email.js';
|
import { verifyEmailToken, verifyResetToken, sendVerificationEmail, sendPasswordResetEmail } from './email.js';
|
||||||
import { fail } from '@zen/core/shared/logger';
|
import { fail } from '@zen/core/shared/logger';
|
||||||
@@ -121,7 +121,8 @@ export async function loginAction(formData) {
|
|||||||
const botCheck = validateAntiBotFields(formData);
|
const botCheck = validateAntiBotFields(formData);
|
||||||
if (!botCheck.valid) return { success: false, error: botCheck.error };
|
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');
|
const rl = enforceRateLimit(ip, 'login');
|
||||||
if (rl && !rl.allowed) {
|
if (rl && !rl.allowed) {
|
||||||
return { success: false, error: `Trop de tentatives. Réessayez dans ${formatRetryAfter(rl.retryAfterMs)}.` };
|
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 email = formData.get('email');
|
||||||
const password = formData.get('password');
|
const password = formData.get('password');
|
||||||
|
const userAgent = h.get('user-agent') || null;
|
||||||
const result = await login({ email, password });
|
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
|
// An HttpOnly cookie is the only safe transport for session tokens; setting it
|
||||||
// here keeps the token out of any JavaScript-readable response payload.
|
// 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) {
|
export async function verifyEmailAction(formData) {
|
||||||
try {
|
try {
|
||||||
const ip = await getClientIp();
|
const ip = await getClientIp();
|
||||||
|
|||||||
+135
-19
@@ -7,11 +7,12 @@
|
|||||||
* the context argument: (request, params, { session }).
|
* 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 { updateUser, requestPasswordReset } from './auth.js';
|
||||||
import { hashPassword, verifyPassword } from '../../core/users/password.js';
|
import { hashPassword, verifyPassword, generateId } from '../../core/users/password.js';
|
||||||
import { createEmailChangeToken, verifyEmailChangeToken, applyEmailChange, sendEmailChangeConfirmEmail, sendEmailChangeOldNotifyEmail, sendEmailChangeNewNotifyEmail, sendPasswordChangedEmail, sendPasswordResetEmail } from './email.js';
|
import { createEmailChangeToken, verifyEmailChangeToken, applyEmailChange, sendEmailChangeConfirmEmail, sendEmailChangeOldNotifyEmail, sendEmailChangeNewNotifyEmail, sendPasswordChangedEmail, sendPasswordResetEmail, sendInvitationEmail } from './email.js';
|
||||||
import { listRoles, getRoleById, createRole, updateRole, deleteRole, getUserRoles, assignUserRole, revokeUserRole, deleteUserSessions } from '@zen/core/users';
|
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 { uploadImage, deleteFile, generateUniqueFilename, getFileExtension, FILE_TYPE_PRESETS, FILE_SIZE_LIMITS, validateUpload } from '@zen/core/storage';
|
||||||
import { getPublicBaseUrl } from '@zen/core/shared/config';
|
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)
|
// 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 {
|
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);
|
await revokeUserRole(userId, roleId);
|
||||||
return apiSuccess({ success: true });
|
return apiSuccess({ success: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -544,6 +563,21 @@ async function handleListRoles() {
|
|||||||
return apiSuccess({ roles });
|
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)
|
// 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
|
// Route definitions
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -815,7 +929,8 @@ async function handleAdminSendPasswordReset(_request, { id: userId }) {
|
|||||||
// parameterised paths (/users/:id) so they match first.
|
// parameterised paths (/users/:id) so they match first.
|
||||||
|
|
||||||
export const routes = defineApiRoutes([
|
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', method: 'PUT', handler: handleUpdateProfile, auth: 'user' },
|
||||||
{ path: '/users/profile/email', method: 'POST', handler: handleInitiateEmailChange, auth: 'user' },
|
{ path: '/users/profile/email', method: 'POST', handler: handleInitiateEmailChange, auth: 'user' },
|
||||||
{ path: '/users/profile/password', method: 'POST', handler: handleChangeOwnPassword, 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', method: 'DELETE', handler: handleDeleteAllSessions, auth: 'user' },
|
||||||
{ path: '/users/profile/sessions/:sessionId', method: 'DELETE', handler: handleDeleteSession, 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/email/confirm', method: 'GET', handler: handleConfirmEmailChange, auth: 'user' },
|
||||||
{ path: '/users/:id/roles', method: 'GET', handler: handleGetUserRoles, 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' },
|
{ 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' },
|
{ path: '/users/:id/roles/:roleId', method: 'DELETE', handler: handleRevokeUserRole, auth: 'admin', permission: PERMISSIONS.USERS_MANAGE },
|
||||||
{ path: '/users/:id', method: 'GET', handler: handleGetUserById, auth: 'admin' },
|
{ path: '/users/:id', method: 'GET', handler: handleGetUserById, auth: 'admin', permission: PERMISSIONS.USERS_VIEW },
|
||||||
{ path: '/users/:id', method: 'PUT', handler: handleUpdateUserById, auth: 'admin' },
|
{ path: '/users/:id', method: 'PUT', handler: handleUpdateUserById, auth: 'admin', permission: PERMISSIONS.USERS_MANAGE },
|
||||||
{ path: '/users/:id/email', method: 'PUT', handler: handleAdminUpdateUserEmail, auth: 'admin' },
|
{ path: '/users/:id/email', method: 'PUT', handler: handleAdminUpdateUserEmail, auth: 'admin', permission: PERMISSIONS.USERS_MANAGE },
|
||||||
{ path: '/users/:id/password', method: 'PUT', handler: handleAdminSetUserPassword, auth: 'admin' },
|
{ 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' },
|
{ path: '/users/:id/send-password-reset', method: 'POST', handler: handleAdminSendPasswordReset, auth: 'admin', permission: PERMISSIONS.USERS_MANAGE },
|
||||||
{ path: '/roles', method: 'GET', handler: handleListRoles, auth: 'admin' },
|
{ path: '/roles', method: 'GET', handler: handleListRoles, auth: 'admin', permission: PERMISSIONS.ROLES_VIEW },
|
||||||
{ path: '/roles', method: 'POST', handler: handleCreateRole, auth: 'admin' },
|
{ path: '/permissions', method: 'GET', handler: handleListPermissions, auth: 'admin', permission: PERMISSIONS.ROLES_VIEW },
|
||||||
{ path: '/roles/:id', method: 'GET', handler: handleGetRole, auth: 'admin' },
|
{ path: '/roles', method: 'POST', handler: handleCreateRole, auth: 'admin', permission: PERMISSIONS.ROLES_MANAGE },
|
||||||
{ path: '/roles/:id', method: 'PUT', handler: handleUpdateRole, auth: 'admin' },
|
{ path: '/roles/:id', method: 'GET', handler: handleGetRole, auth: 'admin', permission: PERMISSIONS.ROLES_VIEW },
|
||||||
{ path: '/roles/:id', method: 'DELETE', handler: handleDeleteRole, auth: 'admin' },
|
{ 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,
|
login,
|
||||||
requestPasswordReset,
|
requestPasswordReset,
|
||||||
verifyUserEmail,
|
verifyUserEmail,
|
||||||
updateUser
|
updateUser,
|
||||||
|
completeAccountSetup
|
||||||
} from '../../core/users/auth.js';
|
} from '../../core/users/auth.js';
|
||||||
import { sendPasswordChangedEmail } from './email.js';
|
import { sendPasswordChangedEmail } from './email.js';
|
||||||
|
|
||||||
@@ -19,4 +20,4 @@ export function resetPassword(resetData) {
|
|||||||
return _resetPassword(resetData, { onPasswordChanged: sendPasswordChangedEmail });
|
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 { PasswordChangedEmail } from './templates/PasswordChangedEmail.js';
|
||||||
import { EmailChangeConfirmEmail } from './templates/EmailChangeConfirmEmail.js';
|
import { EmailChangeConfirmEmail } from './templates/EmailChangeConfirmEmail.js';
|
||||||
import { EmailChangeNotifyEmail } from './templates/EmailChangeNotifyEmail.js';
|
import { EmailChangeNotifyEmail } from './templates/EmailChangeNotifyEmail.js';
|
||||||
|
import { InvitationEmail } from './templates/InvitationEmail.js';
|
||||||
|
|
||||||
export { createEmailVerification, verifyEmailToken, createPasswordReset, verifyResetToken, deleteResetToken }
|
export { createEmailVerification, verifyEmailToken, createPasswordReset, verifyResetToken, deleteResetToken }
|
||||||
from '../../core/users/verifications.js';
|
from '../../core/users/verifications.js';
|
||||||
@@ -93,4 +94,17 @@ async function sendEmailChangeNewNotifyEmail(newEmail, oldEmail) {
|
|||||||
return result;
|
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 };
|
||||||
|
|||||||
+11
-16
@@ -1,6 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* Zen Authentication — server barrel.
|
* Zen Authentication — server barrel (Next.js-free).
|
||||||
* Server actions live in @zen/core/features/auth/actions.
|
*
|
||||||
|
* Ne re-exporte PAS actions.js — ce fichier importe `next/headers` au niveau
|
||||||
|
* top-level et ne peut pas être tiré via ce barrel (qui peut être importé par
|
||||||
|
* des modules externes pendant leur register(), avant que Next.js ait activé
|
||||||
|
* ses alias de modules). Importer les server actions via
|
||||||
|
* @zen/core/features/auth/actions.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export {
|
export {
|
||||||
@@ -9,7 +14,8 @@ export {
|
|||||||
requestPasswordReset,
|
requestPasswordReset,
|
||||||
resetPassword,
|
resetPassword,
|
||||||
verifyUserEmail,
|
verifyUserEmail,
|
||||||
updateUser
|
updateUser,
|
||||||
|
completeAccountSetup
|
||||||
} from './auth.js';
|
} from './auth.js';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
@@ -28,7 +34,8 @@ export {
|
|||||||
deleteResetToken,
|
deleteResetToken,
|
||||||
sendVerificationEmail,
|
sendVerificationEmail,
|
||||||
sendPasswordResetEmail,
|
sendPasswordResetEmail,
|
||||||
sendPasswordChangedEmail
|
sendPasswordChangedEmail,
|
||||||
|
sendInvitationEmail
|
||||||
} from './email.js';
|
} from './email.js';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
@@ -37,15 +44,3 @@ export {
|
|||||||
generateToken,
|
generateToken,
|
||||||
generateId
|
generateId
|
||||||
} from './password.js';
|
} from './password.js';
|
||||||
|
|
||||||
export {
|
|
||||||
registerAction,
|
|
||||||
loginAction,
|
|
||||||
logoutAction,
|
|
||||||
getSession,
|
|
||||||
forgotPasswordAction,
|
|
||||||
resetPasswordAction,
|
|
||||||
verifyEmailAction,
|
|
||||||
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 });
|
console.log('ConfirmEmailPage render', { success, error, isLoading, hasVerified });
|
||||||
|
|
||||||
return (
|
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..." />
|
<AuthPageHeader title="Vérification de l'e-mail" description="Nous vérifions votre adresse e-mail..." />
|
||||||
|
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export default function ForgotPasswordPage({ onSubmit, onNavigate, currentUser =
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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." />
|
<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 && (
|
{currentUser && (
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ export default function LoginPage({ onSubmit, onNavigate, onSetSessionCookie, re
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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." />
|
<AuthPageHeader title="Connexion" description="Veuillez vous connecter pour continuer." />
|
||||||
|
|
||||||
{currentUser && (
|
{currentUser && (
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export default function LogoutPage({ onLogout, onSetSessionCookie }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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." />
|
<AuthPageHeader title="Prêt à vous déconnecter ?" description="Cela mettra fin à votre session et vous déconnectera de votre compte." />
|
||||||
|
|
||||||
{success && (
|
{success && (
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ export default function RegisterPage({ onSubmit, onNavigate, currentUser = null
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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." />
|
<AuthPageHeader title="Créer un compte" description="Inscrivez-vous pour commencer." />
|
||||||
|
|
||||||
{currentUser && (
|
{currentUser && (
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ export default function ResetPasswordPage({ onSubmit, onNavigate, email, token }
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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." />
|
<AuthPageHeader title="Réinitialiser le mot de passe" description="Saisissez votre nouveau mot de passe ci-dessous." />
|
||||||
|
|
||||||
{error && !success && (
|
{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 ResetPasswordPage } from './ResetPasswordPage.client.js';
|
||||||
export { default as ConfirmEmailPage } from './ConfirmEmailPage.client.js';
|
export { default as ConfirmEmailPage } from './ConfirmEmailPage.client.js';
|
||||||
export { default as LogoutPage } from './LogoutPage.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>
|
</Section>
|
||||||
|
|
||||||
<Text className="text-[12px] leading-[20px] text-neutral-400 m-0">
|
<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>
|
||||||
|
|
||||||
<Text className="text-[12px] leading-[20px] text-neutral-400 m-0 mt-[8px]">
|
<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}`,
|
preview: (name) => `Demande de modification de courriel – ${name}`,
|
||||||
title: 'Demande de modification de courriel',
|
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.`,
|
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: {
|
changed: {
|
||||||
preview: (name) => `Votre adresse courriel a été modifiée – ${name}`,
|
preview: (name) => `Votre adresse courriel a été modifiée – ${name}`,
|
||||||
title: 'Adresse courriel modifiée',
|
title: 'Adresse courriel modifiée',
|
||||||
body: (name) => `L'adresse courriel de votre compte ${name} a été modifiée par un administrateur.`,
|
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: {
|
admin_new: {
|
||||||
preview: (name) => `Votre compte est maintenant associé à cette adresse – ${name}`,
|
preview: (name) => `Votre compte est maintenant associé à cette adresse – ${name}`,
|
||||||
title: 'Adresse courriel associée à votre compte',
|
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.`,
|
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}
|
supportSection={true}
|
||||||
>
|
>
|
||||||
<Text className="text-[14px] leading-[24px] text-neutral-600 mt-[4px] mb-[24px]">
|
<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>
|
</Text>
|
||||||
|
|
||||||
<Section style={{ border: '1px solid #E5E5E5' }} className="bg-neutral-100 rounded-[12px] p-[20px] my-[24px]">
|
<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>
|
</Section>
|
||||||
|
|
||||||
<Text className="text-[12px] leading-[20px] text-neutral-400 m-0">
|
<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>
|
</Text>
|
||||||
</BaseLayout>
|
</BaseLayout>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export const PasswordResetEmail = ({ resetUrl, companyName }) => (
|
|||||||
supportSection={true}
|
supportSection={true}
|
||||||
>
|
>
|
||||||
<Text className="text-[14px] leading-[24px] text-neutral-600 mt-[4px] mb-[24px]">
|
<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>
|
</Text>
|
||||||
|
|
||||||
<Section className="mt-[28px] mb-[32px]">
|
<Section className="mt-[28px] mb-[32px]">
|
||||||
@@ -22,7 +22,7 @@ export const PasswordResetEmail = ({ resetUrl, companyName }) => (
|
|||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
<Text className="text-[12px] leading-[20px] text-neutral-400 m-0">
|
<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>
|
||||||
|
|
||||||
<Text className="text-[12px] leading-[20px] text-neutral-400 m-0 mt-[8px]">
|
<Text className="text-[12px] leading-[20px] text-neutral-400 m-0 mt-[8px]">
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export const VerificationEmail = ({ verificationUrl, companyName }) => (
|
|||||||
supportSection={true}
|
supportSection={true}
|
||||||
>
|
>
|
||||||
<Text className="text-[14px] leading-[24px] text-neutral-600 mt-[4px] mb-[24px]">
|
<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>
|
</Text>
|
||||||
|
|
||||||
<Section className="mt-[28px] mb-[32px]">
|
<Section className="mt-[28px] mb-[32px]">
|
||||||
|
|||||||
+55
-7
@@ -1,25 +1,62 @@
|
|||||||
/**
|
/**
|
||||||
* Core Feature Database Initialization (CLI)
|
* Database initialization for features and modules.
|
||||||
*
|
*
|
||||||
* Initialise et supprime les tables des features core. La liste est aujourd'hui
|
* - Features core : auth (et tout futur core ayant un db.js).
|
||||||
* limitée à auth — le seul feature avec un schéma. Ajouter ici si un autre
|
* - Modules externes : découverts via discoverModules() ; chaque module
|
||||||
* feature gagne un db.js avec createTables()/dropTables().
|
* exporte ses propres createTables/dropTables.
|
||||||
|
*
|
||||||
|
* Les permissions des modules (manifest.permissions) sont enregistrées AVANT
|
||||||
|
* le seed de la BD pour qu'elles soient persistées et auto-attribuées au rôle
|
||||||
|
* admin. register() n'est PAS appelé ici — les enregistrements runtime (pages,
|
||||||
|
* nav, routes) ne sont pas nécessaires pour l'init BD et tireraient des imports
|
||||||
|
* Next.js incompatibles avec le contexte CLI.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createTables as authCreate, dropTables as authDrop } from './auth/db.js';
|
import { createTables as authCreate, dropTables as authDrop } from './auth/db.js';
|
||||||
import { done, fail, info, step } from '@zen/core/shared/logger';
|
import { done, fail, info, step } from '@zen/core/shared/logger';
|
||||||
|
import { loadModulesForCli, 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 },
|
{ name: 'auth', createTables: authCreate, dropTables: authDrop },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
async function loadModules() {
|
||||||
|
await loadModulesForCli();
|
||||||
|
const modules = getRegisteredModules();
|
||||||
|
validateModuleEnvVars(modules);
|
||||||
|
|
||||||
|
// Enregistre les permissions déclarées dans le manifest de chaque module.
|
||||||
|
// register() n'est PAS appelé ici : les enregistrements runtime (pages, nav,
|
||||||
|
// routes, storage) ne servent à rien dans le contexte CLI et tireraient des
|
||||||
|
// imports Next.js (next/headers, JSX) qui ne sont pas disponibles en Node.js.
|
||||||
|
for (const mod of modules) {
|
||||||
|
if (Array.isArray(mod.manifest?.permissions)) {
|
||||||
|
registerPermissions(mod.manifest.permissions);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return modules;
|
||||||
|
}
|
||||||
|
|
||||||
export async function initFeatures() {
|
export async function initFeatures() {
|
||||||
const created = [];
|
const created = [];
|
||||||
const skipped = [];
|
const skipped = [];
|
||||||
|
|
||||||
step('Initializing feature databases...');
|
step('Initializing feature databases...');
|
||||||
|
|
||||||
for (const { 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 {
|
try {
|
||||||
step(`Initializing ${name}...`);
|
step(`Initializing ${name}...`);
|
||||||
if (typeof createTables !== 'function') {
|
if (typeof createTables !== 'function') {
|
||||||
@@ -40,7 +77,18 @@ export async function initFeatures() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function dropFeatures() {
|
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 {
|
try {
|
||||||
if (typeof dropTables !== 'function') {
|
if (typeof dropTables !== 'function') {
|
||||||
info(`${name} has no dropTables function`);
|
info(`${name} has no dropTables function`);
|
||||||
|
|||||||
@@ -7,10 +7,12 @@ const Badge = ({
|
|||||||
variant = 'default',
|
variant = 'default',
|
||||||
size = 'sm',
|
size = 'sm',
|
||||||
color = null,
|
color = null,
|
||||||
|
dot = false,
|
||||||
|
onRemove,
|
||||||
className = '',
|
className = '',
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
const baseClassName = 'inline-flex items-center font-medium border font-ibm-plex-mono';
|
const baseClassName = 'inline-flex items-center gap-1 font-medium border font-ibm-plex-mono';
|
||||||
|
|
||||||
const variants = {
|
const variants = {
|
||||||
default: 'bg-neutral-700/10 text-neutral-700 border-neutral-800/30 dark:bg-neutral-600/10 dark:text-neutral-400 dark:border-neutral-800',
|
default: 'bg-neutral-700/10 text-neutral-700 border-neutral-800/30 dark:bg-neutral-600/10 dark:text-neutral-400 dark:border-neutral-800',
|
||||||
@@ -43,7 +45,20 @@ const Badge = ({
|
|||||||
style={colorStyle}
|
style={colorStyle}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
|
{dot && color && (
|
||||||
|
<span className="inline-block w-2 h-2 rounded-full flex-shrink-0" style={{ backgroundColor: color }} />
|
||||||
|
)}
|
||||||
{children}
|
{children}
|
||||||
|
{onRemove && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => { e.stopPropagation(); onRemove(); }}
|
||||||
|
className="ml-0.5 hover:opacity-70 transition-opacity leading-none"
|
||||||
|
aria-label="Retirer"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,339 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useEffect, useImperativeHandle, useRef, useState, forwardRef } from 'react';
|
||||||
|
import { Add01Icon, DragDropVerticalIcon } from '@zen/core/shared/icons';
|
||||||
|
import { getBlockDef } from './blockRegistry.js';
|
||||||
|
import {
|
||||||
|
getCaretOffset,
|
||||||
|
setCaretOffset,
|
||||||
|
focusEnd,
|
||||||
|
isCaretAtStart,
|
||||||
|
} from './utils/caret.js';
|
||||||
|
|
||||||
|
// Wrapper d'un bloc unique. Gère :
|
||||||
|
// - le contentEditable pour les blocs texte (sync uncontrolled ↔ value)
|
||||||
|
// - les handles à gauche (drag, +)
|
||||||
|
// - les évènements clavier (Enter, Backspace, /, shortcuts markdown)
|
||||||
|
// - le drop zone pour le drag-and-drop natif
|
||||||
|
//
|
||||||
|
// L'orchestrateur (BlockEditor) reçoit des évènements via les callbacks et
|
||||||
|
// mute la liste des blocs en conséquence.
|
||||||
|
|
||||||
|
const Block = forwardRef(function Block(
|
||||||
|
{
|
||||||
|
block,
|
||||||
|
index,
|
||||||
|
numberedIndex,
|
||||||
|
disabled,
|
||||||
|
isDragOverTop,
|
||||||
|
isDragOverBottom,
|
||||||
|
isSelected,
|
||||||
|
onContentChange,
|
||||||
|
onEnter,
|
||||||
|
onBackspaceAtStart,
|
||||||
|
onSlashOpen,
|
||||||
|
onSlashClose,
|
||||||
|
onSelectAllBlocks,
|
||||||
|
onShortcutMatch,
|
||||||
|
onFocus,
|
||||||
|
onDragStart,
|
||||||
|
onDragEnd,
|
||||||
|
onDragOver,
|
||||||
|
onDragLeave,
|
||||||
|
onDrop,
|
||||||
|
onPlusClick,
|
||||||
|
},
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
|
const def = getBlockDef(block.type);
|
||||||
|
const editableRef = useRef(null);
|
||||||
|
const [hovered, setHovered] = useState(false);
|
||||||
|
const [draggable, setDraggable] = useState(false);
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
focus: (offset) => {
|
||||||
|
if (!def?.isText) return;
|
||||||
|
if (typeof offset === 'number') setCaretOffset(editableRef.current, offset);
|
||||||
|
else focusEnd(editableRef.current);
|
||||||
|
},
|
||||||
|
getElement: () => editableRef.current,
|
||||||
|
}), [def]);
|
||||||
|
|
||||||
|
// Synchronisation contrôlée → DOM : on n'écrit dans le contentEditable que
|
||||||
|
// si la valeur externe diffère de ce qui est affiché (cas undo/redo, ou
|
||||||
|
// transformation de bloc). Sinon on laisse le DOM gérer pour préserver le caret.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!def?.isText) return;
|
||||||
|
const el = editableRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const next = block.content ?? '';
|
||||||
|
if (el.textContent !== next) {
|
||||||
|
el.textContent = next;
|
||||||
|
}
|
||||||
|
}, [block.content, def?.isText]);
|
||||||
|
|
||||||
|
if (!def) {
|
||||||
|
return (
|
||||||
|
<div className="text-xs text-red-500 px-2 py-1">
|
||||||
|
Type de bloc inconnu : {block.type}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleInput() {
|
||||||
|
const el = editableRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const text = el.textContent ?? '';
|
||||||
|
|
||||||
|
// Détection slash menu : "/" au tout début ou après un espace
|
||||||
|
if (!disabled && text === '/' && getCaretOffset(el) === 1) {
|
||||||
|
onSlashOpen?.({ blockId: block.id, query: '' });
|
||||||
|
} else if (text.startsWith('/') && !text.includes(' ', 1)) {
|
||||||
|
onSlashOpen?.({ blockId: block.id, query: text.slice(1) });
|
||||||
|
} else {
|
||||||
|
onSlashClose?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Markdown shortcut : si le contenu commence par un préfixe connu suivi
|
||||||
|
// d'un espace, on demande à l'orchestrateur de transformer le bloc.
|
||||||
|
if (!def.disableShortcuts) {
|
||||||
|
const match = text.match(/^(#{1,6} |- |1\. |> |```\s|---)/);
|
||||||
|
if (match) {
|
||||||
|
const prefix = match[1];
|
||||||
|
onShortcutMatch?.({ blockId: block.id, prefix, rest: text.slice(prefix.length) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onContentChange?.(block.id, text);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyDown(e) {
|
||||||
|
// Si le slash menu est ouvert (texte commence par "/" sans espace),
|
||||||
|
// ne pas intercepter Enter / Arrow / Escape — l'orchestrateur les gère.
|
||||||
|
const el = editableRef.current;
|
||||||
|
const text = el?.textContent ?? '';
|
||||||
|
const slashOpen = text.startsWith('/') && !text.slice(1).includes(' ');
|
||||||
|
if (slashOpen && ['Enter', 'ArrowUp', 'ArrowDown', 'Escape'].includes(e.key)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === '/') {
|
||||||
|
// L'ouverture se fait dans handleInput après que le caractère soit écrit.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ctrl/Cmd+A : 1er appui → sélectionne le contenu du bloc courant.
|
||||||
|
// 2e appui (le contenu est déjà entièrement sélectionné) → bascule en
|
||||||
|
// sélection multi-blocs (tous les blocs).
|
||||||
|
if ((e.ctrlKey || e.metaKey) && (e.key === 'a' || e.key === 'A')) {
|
||||||
|
if (el) {
|
||||||
|
e.preventDefault();
|
||||||
|
const sel = window.getSelection();
|
||||||
|
const txt = el.textContent ?? '';
|
||||||
|
const fullySelected =
|
||||||
|
!!sel && sel.rangeCount > 0 && !sel.isCollapsed &&
|
||||||
|
el.contains(sel.anchorNode) && el.contains(sel.focusNode) &&
|
||||||
|
sel.toString() === txt && txt.length > 0;
|
||||||
|
if (fullySelected) {
|
||||||
|
onSelectAllBlocks?.();
|
||||||
|
} else {
|
||||||
|
const range = document.createRange();
|
||||||
|
range.selectNodeContents(el);
|
||||||
|
sel?.removeAllRanges();
|
||||||
|
sel?.addRange(range);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
const el = editableRef.current;
|
||||||
|
const offset = el ? getCaretOffset(el) : 0;
|
||||||
|
const text = el?.textContent ?? '';
|
||||||
|
onEnter?.({ blockId: block.id, offset, text });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === 'Backspace') {
|
||||||
|
const el = editableRef.current;
|
||||||
|
const sel = typeof window !== 'undefined' ? window.getSelection() : null;
|
||||||
|
// Si une sélection non-vide existe (ex. après Ctrl+A), laisser le
|
||||||
|
// navigateur supprimer le texte sélectionné — ne pas déclencher le
|
||||||
|
// merge avec le bloc précédent.
|
||||||
|
if (el && sel?.isCollapsed !== false && isCaretAtStart(el)) {
|
||||||
|
e.preventDefault();
|
||||||
|
onBackspaceAtStart?.({ blockId: block.id, text: el.textContent ?? '' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePaste(e) {
|
||||||
|
// MVP : on colle uniquement du texte brut pour éviter le HTML externe.
|
||||||
|
e.preventDefault();
|
||||||
|
const text = e.clipboardData.getData('text/plain');
|
||||||
|
if (!text) return;
|
||||||
|
document.execCommand('insertText', false, text);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleHandleMouseDown() {
|
||||||
|
setDraggable(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragStart(e) {
|
||||||
|
if (!draggable) {
|
||||||
|
e.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
e.dataTransfer.effectAllowed = 'move';
|
||||||
|
e.dataTransfer.setData('text/plain', block.id);
|
||||||
|
onDragStart?.(block.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragEnd() {
|
||||||
|
setDraggable(false);
|
||||||
|
onDragEnd?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragOver(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.dataTransfer.dropEffect = 'move';
|
||||||
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
|
const isTop = e.clientY < rect.top + rect.height / 2;
|
||||||
|
onDragOver?.(block.id, isTop ? 'top' : 'bottom');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDrop(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const sourceId = e.dataTransfer.getData('text/plain');
|
||||||
|
if (sourceId && sourceId !== block.id) {
|
||||||
|
onDrop?.(sourceId, block.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isEmpty = def.isText && (!block.content || block.content.length === 0);
|
||||||
|
|
||||||
|
const dropIndicator = (
|
||||||
|
<>
|
||||||
|
{isDragOverTop && (
|
||||||
|
<div className="absolute left-0 right-0 -top-px h-0.5 bg-blue-500 dark:bg-blue-400 pointer-events-none" />
|
||||||
|
)}
|
||||||
|
{isDragOverBottom && (
|
||||||
|
<div className="absolute left-0 right-0 -bottom-px h-0.5 bg-blue-500 dark:bg-blue-400 pointer-events-none" />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="relative group flex items-start gap-1 px-1 py-1.5"
|
||||||
|
onMouseEnter={() => setHovered(true)}
|
||||||
|
onMouseLeave={() => setHovered(false)}
|
||||||
|
draggable={draggable}
|
||||||
|
onDragStart={handleDragStart}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragLeave={onDragLeave}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
data-block-id={block.id}
|
||||||
|
>
|
||||||
|
{dropIndicator}
|
||||||
|
{isSelected && (
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 rounded-md bg-blue-500/20 dark:bg-blue-400/20 pointer-events-none"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`flex items-center gap-0.5 pt-1 transition-opacity ${hovered ? 'opacity-100' : 'opacity-0'} ${disabled ? 'pointer-events-none' : ''}`}
|
||||||
|
aria-hidden={!hovered}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
tabIndex={-1}
|
||||||
|
title="Insérer un bloc"
|
||||||
|
onClick={() => onPlusClick?.(block.id)}
|
||||||
|
disabled={disabled}
|
||||||
|
className="w-5 h-5 flex items-center justify-center rounded text-neutral-500 hover:bg-neutral-200 dark:hover:bg-neutral-700/60 hover:text-neutral-900 dark:hover:text-white text-sm leading-none"
|
||||||
|
>
|
||||||
|
<Add01Icon width={14} height={14} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
tabIndex={-1}
|
||||||
|
title="Glisser pour réordonner"
|
||||||
|
onMouseDown={handleHandleMouseDown}
|
||||||
|
disabled={disabled}
|
||||||
|
className="w-5 h-5 flex items-center justify-center rounded text-neutral-500 hover:bg-neutral-200 dark:hover:bg-neutral-700/60 hover:text-neutral-900 dark:hover:text-white text-xs cursor-grab active:cursor-grabbing leading-none"
|
||||||
|
>
|
||||||
|
<DragDropVerticalIcon width={14} height={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
{def.isText ? (
|
||||||
|
def.renderPrefix ? (
|
||||||
|
<div className="flex items-start">
|
||||||
|
{def.renderPrefix({ block, index, numberedIndex })}
|
||||||
|
<TextEditable
|
||||||
|
ref={editableRef}
|
||||||
|
className={`flex-1 outline-none ${def.textClassName || ''} ${isEmpty ? 'block-empty' : ''}`}
|
||||||
|
placeholder={def.placeholder}
|
||||||
|
disabled={disabled}
|
||||||
|
onInput={handleInput}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
onPaste={handlePaste}
|
||||||
|
onFocus={() => onFocus?.(block.id)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<TextEditable
|
||||||
|
ref={editableRef}
|
||||||
|
className={`outline-none ${def.textClassName || ''} ${isEmpty ? 'block-empty' : ''}`}
|
||||||
|
placeholder={def.placeholder}
|
||||||
|
disabled={disabled}
|
||||||
|
onInput={handleInput}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
onPaste={handlePaste}
|
||||||
|
onFocus={() => onFocus?.(block.id)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
) : def.Component ? (
|
||||||
|
<def.Component
|
||||||
|
block={block}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(patch) => onContentChange?.(block.id, patch)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="text-xs text-red-500">Bloc {block.type} sans rendu.</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const TextEditable = forwardRef(function TextEditable(
|
||||||
|
{ className, placeholder, disabled, onInput, onKeyDown, onPaste, onFocus },
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
contentEditable={!disabled}
|
||||||
|
suppressContentEditableWarning
|
||||||
|
role="textbox"
|
||||||
|
aria-multiline="true"
|
||||||
|
data-placeholder={placeholder}
|
||||||
|
className={className}
|
||||||
|
onInput={onInput}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
onPaste={onPaste}
|
||||||
|
onFocus={onFocus}
|
||||||
|
spellCheck="true"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default Block;
|
||||||
@@ -0,0 +1,773 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import Block from './Block.client.js';
|
||||||
|
import SlashMenu, { getSlashItems } from './SlashMenu.client.js';
|
||||||
|
import { getBlockDef, DEFAULT_BLOCK_TYPE } from './blockRegistry.js';
|
||||||
|
import { registerBuiltInBlocks } from './blockTypes/index.js';
|
||||||
|
import { newBlockId } from './utils/ids.js';
|
||||||
|
|
||||||
|
registerBuiltInBlocks();
|
||||||
|
|
||||||
|
const UNDO_DEBOUNCE_MS = 600;
|
||||||
|
const MAX_HISTORY = 50;
|
||||||
|
|
||||||
|
// Mappe les préfixes markdown à un type de bloc.
|
||||||
|
const SHORTCUT_TO_TYPE = {
|
||||||
|
'# ': 'heading_1',
|
||||||
|
'## ': 'heading_2',
|
||||||
|
'### ': 'heading_3',
|
||||||
|
'#### ': 'heading_4',
|
||||||
|
'##### ': 'heading_5',
|
||||||
|
'###### ': 'heading_6',
|
||||||
|
'- ': 'bullet_item',
|
||||||
|
'1. ': 'numbered_item',
|
||||||
|
'> ': 'quote',
|
||||||
|
'```': 'code',
|
||||||
|
'---': 'divider',
|
||||||
|
};
|
||||||
|
|
||||||
|
function makeBlock(type, init) {
|
||||||
|
const def = getBlockDef(type) || getBlockDef(DEFAULT_BLOCK_TYPE);
|
||||||
|
return def.create(init || {});
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameSet(a, b) {
|
||||||
|
if (a === b) return true;
|
||||||
|
if (a.size !== b.size) return false;
|
||||||
|
for (const v of a) if (!b.has(v)) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureNonEmpty(blocks) {
|
||||||
|
if (!Array.isArray(blocks) || blocks.length === 0) {
|
||||||
|
return [makeBlock(DEFAULT_BLOCK_TYPE)];
|
||||||
|
}
|
||||||
|
return blocks;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BlockEditor({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
label,
|
||||||
|
error,
|
||||||
|
placeholder,
|
||||||
|
disabled = false,
|
||||||
|
className = '',
|
||||||
|
enabledBlocks,
|
||||||
|
}) {
|
||||||
|
const blocks = useMemo(() => ensureNonEmpty(value), [value]);
|
||||||
|
const blockRefs = useRef(new Map());
|
||||||
|
const containerRef = useRef(null);
|
||||||
|
const [focusBlockId, setFocusBlockId] = useState(null);
|
||||||
|
const [focusOffset, setFocusOffset] = useState(null);
|
||||||
|
const [selectedBlockIds, setSelectedBlockIds] = useState(() => new Set());
|
||||||
|
|
||||||
|
function clearBlockSelection() {
|
||||||
|
setSelectedBlockIds(prev => (prev.size === 0 ? prev : new Set()));
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectAllBlocks() {
|
||||||
|
setSelectedBlockIds(new Set(blocks.map(b => b.id)));
|
||||||
|
const sel = typeof window !== 'undefined' ? window.getSelection() : null;
|
||||||
|
sel?.removeAllRanges();
|
||||||
|
if (document.activeElement instanceof HTMLElement) document.activeElement.blur();
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteSelectedBlocks() {
|
||||||
|
if (selectedBlockIds.size === 0) return;
|
||||||
|
const next = blocks.filter(b => !selectedBlockIds.has(b.id));
|
||||||
|
const finalNext = next.length === 0 ? [makeBlock(DEFAULT_BLOCK_TYPE)] : next;
|
||||||
|
commitChange(finalNext, { immediate: true });
|
||||||
|
setSelectedBlockIds(new Set());
|
||||||
|
setFocusBlockId(finalNext[0].id);
|
||||||
|
setFocusOffset(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Undo / Redo ---
|
||||||
|
const [undoStack, setUndoStack] = useState([]);
|
||||||
|
const [redoStack, setRedoStack] = useState([]);
|
||||||
|
const undoDebounceRef = useRef(null);
|
||||||
|
|
||||||
|
function pushUndo(prev) {
|
||||||
|
setUndoStack(s => {
|
||||||
|
const next = [...s, prev];
|
||||||
|
return next.length > MAX_HISTORY ? next.slice(1) : next;
|
||||||
|
});
|
||||||
|
setRedoStack([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function commitChange(next, opts = {}) {
|
||||||
|
if (opts.immediate) {
|
||||||
|
if (undoDebounceRef.current) {
|
||||||
|
clearTimeout(undoDebounceRef.current);
|
||||||
|
undoDebounceRef.current = null;
|
||||||
|
}
|
||||||
|
pushUndo(blocks);
|
||||||
|
} else {
|
||||||
|
if (!undoDebounceRef.current) {
|
||||||
|
const snapshot = blocks;
|
||||||
|
undoDebounceRef.current = setTimeout(() => {
|
||||||
|
pushUndo(snapshot);
|
||||||
|
undoDebounceRef.current = null;
|
||||||
|
}, UNDO_DEBOUNCE_MS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onChange?.(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleUndo() {
|
||||||
|
if (undoStack.length === 0) return;
|
||||||
|
if (undoDebounceRef.current) {
|
||||||
|
clearTimeout(undoDebounceRef.current);
|
||||||
|
undoDebounceRef.current = null;
|
||||||
|
}
|
||||||
|
const prev = undoStack[undoStack.length - 1];
|
||||||
|
setRedoStack(r => [...r, blocks]);
|
||||||
|
setUndoStack(s => s.slice(0, -1));
|
||||||
|
onChange?.(prev);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRedo() {
|
||||||
|
if (redoStack.length === 0) return;
|
||||||
|
if (undoDebounceRef.current) {
|
||||||
|
clearTimeout(undoDebounceRef.current);
|
||||||
|
undoDebounceRef.current = null;
|
||||||
|
}
|
||||||
|
const next = redoStack[redoStack.length - 1];
|
||||||
|
setUndoStack(u => [...u, blocks]);
|
||||||
|
setRedoStack(r => r.slice(0, -1));
|
||||||
|
onChange?.(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => () => {
|
||||||
|
if (undoDebounceRef.current) clearTimeout(undoDebounceRef.current);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// --- Numérotation des items numérotés (consécutifs) ---
|
||||||
|
const numberedIndexByBlockId = useMemo(() => {
|
||||||
|
const map = new Map();
|
||||||
|
let n = 0;
|
||||||
|
for (const b of blocks) {
|
||||||
|
if (b.type === 'numbered_item') {
|
||||||
|
n += 1;
|
||||||
|
map.set(b.id, n);
|
||||||
|
} else {
|
||||||
|
n = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [blocks]);
|
||||||
|
|
||||||
|
// --- Focus impératif sur un bloc après mutation ---
|
||||||
|
useEffect(() => {
|
||||||
|
if (!focusBlockId) return;
|
||||||
|
const ref = blockRefs.current.get(focusBlockId);
|
||||||
|
if (ref?.focus) {
|
||||||
|
ref.focus(focusOffset);
|
||||||
|
}
|
||||||
|
setFocusBlockId(null);
|
||||||
|
setFocusOffset(null);
|
||||||
|
}, [focusBlockId, focusOffset, blocks]);
|
||||||
|
|
||||||
|
function setBlockRef(id, ref) {
|
||||||
|
if (ref) blockRefs.current.set(id, ref);
|
||||||
|
else blockRefs.current.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Mutations ---
|
||||||
|
function updateBlock(id, patch) {
|
||||||
|
const next = blocks.map(b => (b.id === id ? { ...b, ...patch } : b));
|
||||||
|
commitChange(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceBlock(id, newBlock, focus = true) {
|
||||||
|
const next = blocks.map(b => (b.id === id ? newBlock : b));
|
||||||
|
commitChange(next, { immediate: true });
|
||||||
|
if (focus) {
|
||||||
|
setFocusBlockId(newBlock.id);
|
||||||
|
setFocusOffset(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertAfter(id, newBlock, focus = true) {
|
||||||
|
const idx = blocks.findIndex(b => b.id === id);
|
||||||
|
if (idx < 0) return;
|
||||||
|
const next = [...blocks.slice(0, idx + 1), newBlock, ...blocks.slice(idx + 1)];
|
||||||
|
commitChange(next, { immediate: true });
|
||||||
|
if (focus) {
|
||||||
|
setFocusBlockId(newBlock.id);
|
||||||
|
setFocusOffset(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeBlock(id, focusPrev = true) {
|
||||||
|
const idx = blocks.findIndex(b => b.id === id);
|
||||||
|
if (idx < 0) return;
|
||||||
|
const next = blocks.filter(b => b.id !== id);
|
||||||
|
const finalNext = next.length === 0 ? [makeBlock(DEFAULT_BLOCK_TYPE)] : next;
|
||||||
|
commitChange(finalNext, { immediate: true });
|
||||||
|
if (focusPrev) {
|
||||||
|
const prev = idx > 0 ? blocks[idx - 1] : finalNext[0];
|
||||||
|
setFocusBlockId(prev.id);
|
||||||
|
setFocusOffset(prev.content?.length ?? 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Évènements provenant des blocs ---
|
||||||
|
function handleContentChange(id, text) {
|
||||||
|
const next = blocks.map(b => (b.id === id ? { ...b, content: text } : b));
|
||||||
|
commitChange(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEnter({ blockId, offset, text }) {
|
||||||
|
const idx = blocks.findIndex(b => b.id === blockId);
|
||||||
|
if (idx < 0) return;
|
||||||
|
const current = blocks[idx];
|
||||||
|
|
||||||
|
// Sur un item de liste vide, sortir de la liste (devient paragraphe)
|
||||||
|
if (
|
||||||
|
(current.type === 'bullet_item' || current.type === 'numbered_item') &&
|
||||||
|
(text ?? '').length === 0
|
||||||
|
) {
|
||||||
|
const replaced = makeBlock(DEFAULT_BLOCK_TYPE);
|
||||||
|
const next = blocks.map(b => (b.id === blockId ? replaced : b));
|
||||||
|
commitChange(next, { immediate: true });
|
||||||
|
setFocusBlockId(replaced.id);
|
||||||
|
setFocusOffset(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const before = (text ?? '').slice(0, offset);
|
||||||
|
const after = (text ?? '').slice(offset);
|
||||||
|
|
||||||
|
// Le bloc courant garde la partie avant le caret
|
||||||
|
const updated = { ...current, content: before };
|
||||||
|
|
||||||
|
// Le nouveau bloc hérite du type pour les listes, sinon paragraphe
|
||||||
|
const newType =
|
||||||
|
current.type === 'bullet_item' || current.type === 'numbered_item'
|
||||||
|
? current.type
|
||||||
|
: DEFAULT_BLOCK_TYPE;
|
||||||
|
const newBlock = makeBlock(newType, { content: after });
|
||||||
|
|
||||||
|
const next = [
|
||||||
|
...blocks.slice(0, idx),
|
||||||
|
updated,
|
||||||
|
newBlock,
|
||||||
|
...blocks.slice(idx + 1),
|
||||||
|
];
|
||||||
|
commitChange(next, { immediate: true });
|
||||||
|
setFocusBlockId(newBlock.id);
|
||||||
|
setFocusOffset(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBackspaceAtStart({ blockId, text }) {
|
||||||
|
const idx = blocks.findIndex(b => b.id === blockId);
|
||||||
|
if (idx < 0) return;
|
||||||
|
const current = blocks[idx];
|
||||||
|
|
||||||
|
// Si le bloc est typé (heading, list, quote, code) et non vide → repasse
|
||||||
|
// en paragraphe sans rien supprimer.
|
||||||
|
if (current.type !== DEFAULT_BLOCK_TYPE) {
|
||||||
|
const replaced = makeBlock(DEFAULT_BLOCK_TYPE, { content: text ?? '' });
|
||||||
|
const next = blocks.map(b => (b.id === blockId ? replaced : b));
|
||||||
|
commitChange(next, { immediate: true });
|
||||||
|
setFocusBlockId(replaced.id);
|
||||||
|
setFocusOffset(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sinon : merge avec le bloc précédent s'il est texte
|
||||||
|
if (idx === 0) return;
|
||||||
|
const prev = blocks[idx - 1];
|
||||||
|
const prevDef = getBlockDef(prev.type);
|
||||||
|
if (!prevDef?.isText) {
|
||||||
|
// précédent non-texte : on supprime juste le bloc courant
|
||||||
|
removeBlock(blockId, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const mergedOffset = (prev.content ?? '').length;
|
||||||
|
const merged = { ...prev, content: (prev.content ?? '') + (text ?? '') };
|
||||||
|
const next = [...blocks.slice(0, idx - 1), merged, ...blocks.slice(idx + 1)];
|
||||||
|
commitChange(next, { immediate: true });
|
||||||
|
setFocusBlockId(merged.id);
|
||||||
|
setFocusOffset(mergedOffset);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleShortcutMatch({ blockId, prefix, rest }) {
|
||||||
|
const newType = SHORTCUT_TO_TYPE[prefix.trimEnd()] || SHORTCUT_TO_TYPE[prefix];
|
||||||
|
if (!newType) return;
|
||||||
|
const def = getBlockDef(newType);
|
||||||
|
if (!def) return;
|
||||||
|
|
||||||
|
if (newType === 'divider') {
|
||||||
|
// Insère un divider et place le caret dans un nouveau paragraphe vide
|
||||||
|
const idx = blocks.findIndex(b => b.id === blockId);
|
||||||
|
if (idx < 0) return;
|
||||||
|
const divider = makeBlock('divider');
|
||||||
|
const after = makeBlock(DEFAULT_BLOCK_TYPE);
|
||||||
|
const next = [
|
||||||
|
...blocks.slice(0, idx),
|
||||||
|
divider,
|
||||||
|
after,
|
||||||
|
...blocks.slice(idx + 1),
|
||||||
|
];
|
||||||
|
commitChange(next, { immediate: true });
|
||||||
|
setFocusBlockId(after.id);
|
||||||
|
setFocusOffset(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const replaced = def.create({ content: rest ?? '' });
|
||||||
|
const next = blocks.map(b => (b.id === blockId ? replaced : b));
|
||||||
|
commitChange(next, { immediate: true });
|
||||||
|
setFocusBlockId(replaced.id);
|
||||||
|
setFocusOffset(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Slash menu ---
|
||||||
|
const [slashState, setSlashState] = useState(null);
|
||||||
|
// { blockId, query, anchorRect, selectedIndex }
|
||||||
|
|
||||||
|
function openSlashFor(blockId, query = '') {
|
||||||
|
const ref = blockRefs.current.get(blockId);
|
||||||
|
const el = ref?.getElement?.();
|
||||||
|
if (!el) return;
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
setSlashState(prev => ({
|
||||||
|
blockId,
|
||||||
|
query,
|
||||||
|
anchorRect: rect,
|
||||||
|
selectedIndex: prev?.blockId === blockId ? prev.selectedIndex ?? 0 : 0,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSlashOpen({ blockId, query }) {
|
||||||
|
openSlashFor(blockId, query);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSlashClose() {
|
||||||
|
setSlashState(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSlashSelect(type) {
|
||||||
|
if (!slashState) return;
|
||||||
|
const { blockId } = slashState;
|
||||||
|
const def = getBlockDef(type);
|
||||||
|
if (!def) return;
|
||||||
|
|
||||||
|
const idx = blocks.findIndex(b => b.id === blockId);
|
||||||
|
if (idx < 0) return;
|
||||||
|
const current = blocks[idx];
|
||||||
|
|
||||||
|
if (def.isText) {
|
||||||
|
// Remplace le bloc courant en gardant son contenu (purgé du / de query)
|
||||||
|
const text = (current.content ?? '').replace(/^\/\S*/, '');
|
||||||
|
const replaced = def.create({ content: text });
|
||||||
|
const next = blocks.map(b => (b.id === blockId ? replaced : b));
|
||||||
|
commitChange(next, { immediate: true });
|
||||||
|
setFocusBlockId(replaced.id);
|
||||||
|
setFocusOffset(text.length);
|
||||||
|
} else {
|
||||||
|
// Bloc non-texte : insère après et nettoie le bloc courant
|
||||||
|
const cleared = { ...current, content: (current.content ?? '').replace(/^\/\S*/, '') };
|
||||||
|
const inserted = def.create();
|
||||||
|
const after =
|
||||||
|
cleared.content.length === 0
|
||||||
|
? // remplacer le bloc courant par le bloc non-texte puis ajouter un paragraphe vide
|
||||||
|
[
|
||||||
|
...blocks.slice(0, idx),
|
||||||
|
inserted,
|
||||||
|
makeBlock(DEFAULT_BLOCK_TYPE),
|
||||||
|
...blocks.slice(idx + 1),
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
...blocks.slice(0, idx),
|
||||||
|
cleared,
|
||||||
|
inserted,
|
||||||
|
makeBlock(DEFAULT_BLOCK_TYPE),
|
||||||
|
...blocks.slice(idx + 1),
|
||||||
|
];
|
||||||
|
commitChange(after, { immediate: true });
|
||||||
|
}
|
||||||
|
setSlashState(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePlusClick(blockId) {
|
||||||
|
const idx = blocks.findIndex(b => b.id === blockId);
|
||||||
|
if (idx < 0) return;
|
||||||
|
const newBlock = makeBlock(DEFAULT_BLOCK_TYPE);
|
||||||
|
const next = [...blocks.slice(0, idx + 1), newBlock, ...blocks.slice(idx + 1)];
|
||||||
|
commitChange(next, { immediate: true });
|
||||||
|
setFocusBlockId(newBlock.id);
|
||||||
|
setFocusOffset(0);
|
||||||
|
// Ouvre le slash menu après la mise à jour
|
||||||
|
setTimeout(() => openSlashFor(newBlock.id, ''), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Drag & drop ---
|
||||||
|
const [dragOver, setDragOver] = useState(null); // { blockId, position: 'top'|'bottom' }
|
||||||
|
|
||||||
|
function handleDragOver(blockId, position) {
|
||||||
|
setDragOver({ blockId, position });
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragLeave() {
|
||||||
|
// ne rien faire de spécial : le prochain dragOver écrasera l'état
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragEnd() {
|
||||||
|
setDragOver(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDrop(sourceId, targetId) {
|
||||||
|
setDragOver(null);
|
||||||
|
if (sourceId === targetId) return;
|
||||||
|
const sourceIdx = blocks.findIndex(b => b.id === sourceId);
|
||||||
|
const targetIdx = blocks.findIndex(b => b.id === targetId);
|
||||||
|
if (sourceIdx < 0 || targetIdx < 0) return;
|
||||||
|
const position = dragOver?.position || 'bottom';
|
||||||
|
const without = blocks.filter(b => b.id !== sourceId);
|
||||||
|
let insertIdx = without.findIndex(b => b.id === targetId);
|
||||||
|
if (position === 'bottom') insertIdx += 1;
|
||||||
|
const moved = blocks[sourceIdx];
|
||||||
|
const next = [...without.slice(0, insertIdx), moved, ...without.slice(insertIdx)];
|
||||||
|
commitChange(next, { immediate: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Raccourcis globaux (Undo/Redo seulement) ---
|
||||||
|
function handleGlobalKeyDown(e) {
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key === 'z') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (e.shiftKey) handleRedo();
|
||||||
|
else handleUndo();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key === 'y') {
|
||||||
|
e.preventDefault();
|
||||||
|
handleRedo();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Sélection multi-blocs (souris) ---
|
||||||
|
// Deux modes selon où le mousedown a démarré :
|
||||||
|
// - `text` : sur un contentEditable. Tant que la souris reste dans le
|
||||||
|
// bloc d'origine, sélection de texte native. Dès qu'elle en
|
||||||
|
// sort, on bascule en mode `block` : caret défocus, sélection
|
||||||
|
// native effacée, on étend la sélection « bloc » de l'origine
|
||||||
|
// jusqu'au bloc sous le curseur.
|
||||||
|
// - `marquee`: mousedown ailleurs (padding du container). Rectangle invisible
|
||||||
|
// de la position de départ jusqu'au curseur ; tous les blocs
|
||||||
|
// qui intersectent sont sélectionnés.
|
||||||
|
const dragRef = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function onMove(e) {
|
||||||
|
const drag = dragRef.current;
|
||||||
|
if (!drag) return;
|
||||||
|
const container = containerRef.current;
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
if (drag.mode === 'text') {
|
||||||
|
if (!drag.startBlockId) return;
|
||||||
|
const startEl = container.querySelector(`[data-block-id="${drag.startBlockId}"]`);
|
||||||
|
if (!startEl) return;
|
||||||
|
const r = startEl.getBoundingClientRect();
|
||||||
|
const outside = e.clientY < r.top || e.clientY > r.bottom;
|
||||||
|
if (outside) {
|
||||||
|
drag.mode = 'block';
|
||||||
|
const sel = document.getSelection();
|
||||||
|
sel?.removeAllRanges();
|
||||||
|
if (document.activeElement instanceof HTMLElement) document.activeElement.blur();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (drag.mode === 'block') {
|
||||||
|
e.preventDefault();
|
||||||
|
const sel = document.getSelection();
|
||||||
|
if (sel && sel.rangeCount > 0 && !sel.isCollapsed) sel.removeAllRanges();
|
||||||
|
const elAt = document.elementFromPoint(e.clientX, e.clientY);
|
||||||
|
const blockEl = elAt?.closest?.('[data-block-id]');
|
||||||
|
let endId = blockEl?.getAttribute('data-block-id') ?? null;
|
||||||
|
if (!endId) {
|
||||||
|
// curseur entre deux blocs : prendre le bloc dont le rect est le
|
||||||
|
// plus proche verticalement
|
||||||
|
const all = container.querySelectorAll('[data-block-id]');
|
||||||
|
let best = null;
|
||||||
|
let bestDist = Infinity;
|
||||||
|
for (const el of all) {
|
||||||
|
const r = el.getBoundingClientRect();
|
||||||
|
const cy = (r.top + r.bottom) / 2;
|
||||||
|
const d = Math.abs(cy - e.clientY);
|
||||||
|
if (d < bestDist) { bestDist = d; best = el; }
|
||||||
|
}
|
||||||
|
endId = best?.getAttribute('data-block-id') ?? drag.startBlockId;
|
||||||
|
}
|
||||||
|
const startIdx = blocks.findIndex(b => b.id === drag.startBlockId);
|
||||||
|
const endIdx = blocks.findIndex(b => b.id === endId);
|
||||||
|
if (startIdx >= 0 && endIdx >= 0) {
|
||||||
|
const [a, b] = startIdx <= endIdx ? [startIdx, endIdx] : [endIdx, startIdx];
|
||||||
|
const ids = new Set(blocks.slice(a, b + 1).map(x => x.id));
|
||||||
|
setSelectedBlockIds(prev => sameSet(prev, ids) ? prev : ids);
|
||||||
|
}
|
||||||
|
} else if (drag.mode === 'marquee') {
|
||||||
|
e.preventDefault();
|
||||||
|
const x1 = Math.min(drag.startX, e.clientX);
|
||||||
|
const y1 = Math.min(drag.startY, e.clientY);
|
||||||
|
const x2 = Math.max(drag.startX, e.clientX);
|
||||||
|
const y2 = Math.max(drag.startY, e.clientY);
|
||||||
|
const ids = new Set();
|
||||||
|
const all = container.querySelectorAll('[data-block-id]');
|
||||||
|
for (const el of all) {
|
||||||
|
const r = el.getBoundingClientRect();
|
||||||
|
if (r.right >= x1 && r.left <= x2 && r.bottom >= y1 && r.top <= y2) {
|
||||||
|
const id = el.getAttribute('data-block-id');
|
||||||
|
if (id) ids.add(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setSelectedBlockIds(prev => sameSet(prev, ids) ? prev : ids);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function onUp() {
|
||||||
|
dragRef.current = null;
|
||||||
|
}
|
||||||
|
document.addEventListener('mousemove', onMove);
|
||||||
|
document.addEventListener('mouseup', onUp);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousemove', onMove);
|
||||||
|
document.removeEventListener('mouseup', onUp);
|
||||||
|
};
|
||||||
|
}, [blocks]);
|
||||||
|
|
||||||
|
// Touches actives pendant qu'une sélection de blocs existe : Backspace/Delete
|
||||||
|
// suppriment, Escape efface, frappe alphanumérique remplace.
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedBlockIds.size === 0) return;
|
||||||
|
function onKey(e) {
|
||||||
|
if ((e.ctrlKey || e.metaKey) && (e.key === 'a' || e.key === 'A')) {
|
||||||
|
// Déjà tout sélectionné — laisser passer (no-op).
|
||||||
|
if (selectedBlockIds.size === blocks.length) return;
|
||||||
|
e.preventDefault();
|
||||||
|
selectAllBlocks();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === 'Backspace' || e.key === 'Delete') {
|
||||||
|
e.preventDefault();
|
||||||
|
deleteSelectedBlocks();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
clearBlockSelection();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ((e.ctrlKey || e.metaKey) && (e.key === 'c' || e.key === 'C' || e.key === 'x' || e.key === 'X')) {
|
||||||
|
// Copy/cut : fallback simple — concatène le contenu texte.
|
||||||
|
const text = blocks
|
||||||
|
.filter(b => selectedBlockIds.has(b.id))
|
||||||
|
.map(b => b.content ?? '')
|
||||||
|
.join('\n');
|
||||||
|
try { e.clipboardData?.setData?.('text/plain', text); } catch {}
|
||||||
|
if (navigator.clipboard) navigator.clipboard.writeText(text).catch(() => {});
|
||||||
|
if (e.key === 'x' || e.key === 'X') {
|
||||||
|
e.preventDefault();
|
||||||
|
deleteSelectedBlocks();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Frappe utile (caractère imprimable) → supprimer puis insérer un nouveau
|
||||||
|
// paragraphe avec ce caractère.
|
||||||
|
if (!e.ctrlKey && !e.metaKey && !e.altKey && e.key.length === 1) {
|
||||||
|
e.preventDefault();
|
||||||
|
const ch = e.key;
|
||||||
|
const next = blocks.filter(b => !selectedBlockIds.has(b.id));
|
||||||
|
const replaced = makeBlock(DEFAULT_BLOCK_TYPE, { content: ch });
|
||||||
|
const finalNext = next.length === 0 ? [replaced] : [replaced, ...next];
|
||||||
|
commitChange(finalNext, { immediate: true });
|
||||||
|
setSelectedBlockIds(new Set());
|
||||||
|
setFocusBlockId(replaced.id);
|
||||||
|
setFocusOffset(ch.length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('keydown', onKey, true);
|
||||||
|
return () => document.removeEventListener('keydown', onKey, true);
|
||||||
|
}, [selectedBlockIds, blocks]);
|
||||||
|
|
||||||
|
function handleContainerMouseDown(e) {
|
||||||
|
if (e.button !== 0) return;
|
||||||
|
const target = e.target;
|
||||||
|
const blockEl = target instanceof Element ? target.closest('[data-block-id]') : null;
|
||||||
|
const editableEl = target instanceof Element ? target.closest('[contenteditable="true"]') : null;
|
||||||
|
const onHandle = target instanceof Element ? target.closest('button') : null;
|
||||||
|
|
||||||
|
// Boutons (poignée +, drag handle…) : ne pas démarrer de sélection.
|
||||||
|
if (onHandle) {
|
||||||
|
if (selectedBlockIds.size > 0) clearBlockSelection();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toute nouvelle interaction → reset de la sélection bloc en cours.
|
||||||
|
if (selectedBlockIds.size > 0) clearBlockSelection();
|
||||||
|
|
||||||
|
if (editableEl && blockEl) {
|
||||||
|
// Démarre potentiellement en mode texte : la bascule en mode bloc se
|
||||||
|
// fait dans onMove dès que le curseur quitte le bloc d'origine.
|
||||||
|
dragRef.current = {
|
||||||
|
mode: 'text',
|
||||||
|
startBlockId: blockEl.getAttribute('data-block-id'),
|
||||||
|
startX: e.clientX,
|
||||||
|
startY: e.clientY,
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (blockEl) {
|
||||||
|
// Clic sur un bloc non-texte (image, divider, …) : commencer en bloc.
|
||||||
|
dragRef.current = {
|
||||||
|
mode: 'block',
|
||||||
|
startBlockId: blockEl.getAttribute('data-block-id'),
|
||||||
|
startX: e.clientX,
|
||||||
|
startY: e.clientY,
|
||||||
|
};
|
||||||
|
setSelectedBlockIds(new Set([blockEl.getAttribute('data-block-id')]));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mousedown ailleurs (padding du container) : marquee invisible.
|
||||||
|
e.preventDefault();
|
||||||
|
dragRef.current = {
|
||||||
|
mode: 'marquee',
|
||||||
|
startBlockId: null,
|
||||||
|
startX: e.clientX,
|
||||||
|
startY: e.clientY,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Escalade Ctrl+A : appelé par un bloc quand son contenu est déjà entièrement
|
||||||
|
// sélectionné — on bascule en sélection « tous les blocs ».
|
||||||
|
function handleBlockSelectAll() {
|
||||||
|
selectAllBlocks();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Le slash menu utilise un listener au niveau document en phase capture pour
|
||||||
|
// intercepter les touches avant que le contentEditable ne gère ses défauts
|
||||||
|
// (sinon ↑/↓ déplacent le caret et Entrée insère une nouvelle ligne).
|
||||||
|
useEffect(() => {
|
||||||
|
if (!slashState) return;
|
||||||
|
function onKey(e) {
|
||||||
|
if (!slashState) return;
|
||||||
|
const items = getSlashItems(slashState.query, enabledBlocks);
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setSlashState(s => s && ({
|
||||||
|
...s,
|
||||||
|
selectedIndex: items.length === 0 ? 0 : ((s.selectedIndex ?? 0) + 1) % items.length,
|
||||||
|
}));
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setSlashState(s => s && ({
|
||||||
|
...s,
|
||||||
|
selectedIndex: items.length === 0 ? 0 : ((s.selectedIndex ?? 0) - 1 + items.length) % items.length,
|
||||||
|
}));
|
||||||
|
} else if (e.key === 'Enter') {
|
||||||
|
if (items.length > 0) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
const def = items[slashState.selectedIndex ?? 0];
|
||||||
|
if (def) handleSlashSelect(def.type);
|
||||||
|
}
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setSlashState(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('keydown', onKey, true);
|
||||||
|
return () => document.removeEventListener('keydown', onKey, true);
|
||||||
|
}, [slashState, enabledBlocks]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{label && (
|
||||||
|
<label className="block text-xs font-medium text-neutral-700 dark:text-neutral-400">
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
onKeyDown={handleGlobalKeyDown}
|
||||||
|
onMouseDownCapture={handleContainerMouseDown}
|
||||||
|
className={`block-editor border rounded-xl bg-white dark:bg-neutral-900/60 px-3 py-6 ${error ? 'border-red-500/50' : 'border-neutral-300 dark:border-neutral-700/50'} ${className}`}
|
||||||
|
>
|
||||||
|
<BlockEditorStyles />
|
||||||
|
{placeholder && blocks.length === 1 && !blocks[0].content && (
|
||||||
|
// Placeholder global injecté via data-placeholder du paragraphe initial
|
||||||
|
null
|
||||||
|
)}
|
||||||
|
{blocks.map((block, i) => (
|
||||||
|
<Block
|
||||||
|
key={block.id}
|
||||||
|
ref={(r) => setBlockRef(block.id, r)}
|
||||||
|
block={block}
|
||||||
|
index={i}
|
||||||
|
numberedIndex={numberedIndexByBlockId.get(block.id)}
|
||||||
|
disabled={disabled}
|
||||||
|
isDragOverTop={dragOver?.blockId === block.id && dragOver.position === 'top'}
|
||||||
|
isDragOverBottom={dragOver?.blockId === block.id && dragOver.position === 'bottom'}
|
||||||
|
isSelected={selectedBlockIds.has(block.id)}
|
||||||
|
onContentChange={handleContentChange}
|
||||||
|
onEnter={handleEnter}
|
||||||
|
onBackspaceAtStart={handleBackspaceAtStart}
|
||||||
|
onShortcutMatch={handleShortcutMatch}
|
||||||
|
onSlashOpen={handleSlashOpen}
|
||||||
|
onSlashClose={handleSlashClose}
|
||||||
|
onSelectAllBlocks={handleBlockSelectAll}
|
||||||
|
onDragStart={() => setDragOver(null)}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
onPlusClick={handlePlusClick}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{slashState && (
|
||||||
|
<SlashMenu
|
||||||
|
query={slashState.query}
|
||||||
|
anchorRect={slashState.anchorRect}
|
||||||
|
enabledBlocks={enabledBlocks}
|
||||||
|
selectedIndex={slashState.selectedIndex ?? 0}
|
||||||
|
onSelect={handleSlashSelect}
|
||||||
|
onHoverIndex={(i) => setSlashState(s => ({ ...s, selectedIndex: i }))}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<p className="text-red-600 dark:text-red-400 text-xs">{error}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Styles minimaux : placeholder pour les contentEditable vides.
|
||||||
|
function BlockEditorStyles() {
|
||||||
|
return (
|
||||||
|
<style>{`
|
||||||
|
.block-editor [contenteditable][data-placeholder]:empty::before {
|
||||||
|
content: '';
|
||||||
|
color: rgb(163 163 163);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.block-editor .group:hover [contenteditable][data-placeholder]:empty::before,
|
||||||
|
.block-editor [contenteditable][data-placeholder]:empty:focus::before {
|
||||||
|
content: attr(data-placeholder);
|
||||||
|
}
|
||||||
|
.dark .block-editor [contenteditable][data-placeholder]:empty::before {
|
||||||
|
color: rgb(82 82 82);
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
# BlockEditor
|
||||||
|
|
||||||
|
Éditeur WYSIWYG par blocs, style Notion. Construit en interne (pas de
|
||||||
|
ProseMirror/Lexical/Tiptap). Un `contentEditable` par bloc, pas un seul
|
||||||
|
contentEditable global — c'est plus robuste et plus simple à étendre.
|
||||||
|
|
||||||
|
## Utilisation
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
import { BlockEditor } from '@zen/core/shared/components';
|
||||||
|
|
||||||
|
const [blocks, setBlocks] = useState([]);
|
||||||
|
<BlockEditor value={blocks} onChange={setBlocks} label="Contenu" />
|
||||||
|
```
|
||||||
|
|
||||||
|
`value` est un **tableau de blocs JSON**. C'est la source de vérité.
|
||||||
|
`MarkdownEditor` reste disponible en parallèle pour les usages markdown.
|
||||||
|
|
||||||
|
## Format des blocs (Phase 1)
|
||||||
|
|
||||||
|
Chaque bloc a un `id` (UUID) et un `type`. Selon le type :
|
||||||
|
|
||||||
|
| type | champs | description |
|
||||||
|
|----------------|---------------|----------------------------|
|
||||||
|
| `paragraph` | `content` | texte brut |
|
||||||
|
| `heading_1..6` | `content` | titre niveau 1 à 6 |
|
||||||
|
| `bullet_item` | `content` | élément de liste à puces |
|
||||||
|
| `numbered_item`| `content` | élément de liste numérotée |
|
||||||
|
| `quote` | `content` | citation |
|
||||||
|
| `code` | `content` | bloc de code (monospace) |
|
||||||
|
| `divider` | — | séparateur horizontal |
|
||||||
|
|
||||||
|
Phase 2 ajoutera : `checklist`, `image`, et le format de `content` passera de
|
||||||
|
`string` à `InlineNode[]` pour supporter le formatting inline (gras, italique,
|
||||||
|
couleur, lien). Phase 3 : `table`.
|
||||||
|
|
||||||
|
## Props
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
<BlockEditor
|
||||||
|
value={blocks} // Block[]
|
||||||
|
onChange={setBlocks} // (Block[]) => void
|
||||||
|
label, error, placeholder, disabled, className
|
||||||
|
enabledBlocks={[...]} // optionnel : restreindre les types disponibles
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Interactions clavier
|
||||||
|
|
||||||
|
- `/` → ouvre le menu de commandes (filtrable au clavier, ↑ ↓ Entrée pour valider, Échap pour fermer)
|
||||||
|
- `# ` → titre 1, `## ` → 2, …, `###### ` → 6
|
||||||
|
- `- ` → liste à puces
|
||||||
|
- `1. ` → liste numérotée
|
||||||
|
- `> ` → citation
|
||||||
|
- ` ``` ` → bloc de code
|
||||||
|
- `---` → séparateur
|
||||||
|
- `Backspace` au début d'un bloc typé → repasse en paragraphe ; au début d'un paragraphe, fusionne avec le bloc précédent (uniquement si la sélection est repliée — sinon le navigateur supprime le texte sélectionné, ex. après `Ctrl+A`)
|
||||||
|
- `Entrée` sur un item de liste vide → sort de la liste
|
||||||
|
- `Ctrl/Cmd + Z` / `Ctrl/Cmd + Shift + Z` → undo / redo
|
||||||
|
- `Ctrl/Cmd + A` → 1er appui : sélectionne le contenu du bloc courant ; 2e appui : sélectionne **tous les blocs** (mode sélection multi-blocs)
|
||||||
|
|
||||||
|
## Sélection multi-blocs
|
||||||
|
|
||||||
|
Deux façons d'entrer en mode sélection multi-blocs :
|
||||||
|
|
||||||
|
- **Souris** : un drag qui traverse plusieurs blocs bascule automatiquement en sélection bloc (les contenteditables sont défocus, surlignage bleu transparent sur les blocs sélectionnés). Évite la fusion accidentelle de texte entre blocs lors d'un Backspace/Delete.
|
||||||
|
- **Clavier** : double `Ctrl/Cmd + A` (cf. ci-dessus).
|
||||||
|
|
||||||
|
En mode sélection multi-blocs :
|
||||||
|
- `Backspace` / `Delete` → supprime tous les blocs sélectionnés
|
||||||
|
- `Escape` → quitte la sélection
|
||||||
|
- `Ctrl/Cmd + A` → étend à tous les blocs (no-op si déjà tous sélectionnés)
|
||||||
|
- `Ctrl/Cmd + C` / `Ctrl/Cmd + X` → copie/coupe le texte concaténé
|
||||||
|
- frappe d'un caractère imprimable → remplace les blocs sélectionnés par un nouveau paragraphe contenant ce caractère
|
||||||
|
- clic dans l'éditeur → quitte la sélection
|
||||||
|
|
||||||
|
## Drag and drop
|
||||||
|
|
||||||
|
Chaque bloc affiche au survol :
|
||||||
|
- une poignée `Add01Icon` pour insérer un bloc en dessous (ouvre le slash menu)
|
||||||
|
- une poignée `DragDropVerticalIcon` pour glisser-déposer (réordonner)
|
||||||
|
|
||||||
|
Les icônes proviennent de [`src/shared/icons/index.js`](../../icons/index.js).
|
||||||
|
|
||||||
|
Le drag-and-drop utilise l'API HTML5 native, pas de dépendance externe.
|
||||||
|
|
||||||
|
## Étendre — enregistrer un bloc custom
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { registerBlock, newBlockId } from '@zen/core/shared/components/BlockEditor';
|
||||||
|
|
||||||
|
registerBlock({
|
||||||
|
type: 'kpi',
|
||||||
|
label: 'KPI',
|
||||||
|
icon: '📊',
|
||||||
|
keywords: ['kpi', 'metric', 'stat'],
|
||||||
|
isText: false,
|
||||||
|
create: () => ({ id: newBlockId(), type: 'kpi', value: 0 }),
|
||||||
|
Component: ({ block, onChange, disabled }) => (
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={block.value}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(e) => onChange({ value: Number(e.target.value) })}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Le nouveau type apparaît automatiquement dans le slash menu de tout
|
||||||
|
`<BlockEditor />` rendu après l'enregistrement. Pour les blocs **texte**, on
|
||||||
|
fournit à la place `isText: true`, `textTag`, `textClassName`, et optionnellement
|
||||||
|
`renderPrefix({ block, index, numberedIndex })` pour un préfixe (puce, numéro).
|
||||||
|
|
||||||
|
## Architecture interne
|
||||||
|
|
||||||
|
```
|
||||||
|
BlockEditor.client.js orchestrateur : value/onChange, undo, slash menu, drag-drop
|
||||||
|
Block.client.js wrapper d'un bloc : handles, contentEditable, paste sanitize
|
||||||
|
SlashMenu.client.js menu flottant filtrable
|
||||||
|
blockRegistry.js map type → définition, API publique d'extension
|
||||||
|
blockTypes/ un fichier par type built-in
|
||||||
|
utils/ids.js UUID pour les blocs
|
||||||
|
utils/caret.js gestion du caret dans un contentEditable
|
||||||
|
```
|
||||||
|
|
||||||
|
## Limitations connues (Phase 1)
|
||||||
|
|
||||||
|
- Inline formatting (gras, italique, couleur, lien) **pas encore** : tout est texte brut. Phase 2.
|
||||||
|
- Pas d'imbrication de listes.
|
||||||
|
- Paste : seul le texte brut est conservé (sanitize HTML).
|
||||||
|
- Tables : Phase 3.
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
const MENU_WIDTH = 375;
|
||||||
|
const MENU_MAX_HEIGHT = 360;
|
||||||
|
const VIEWPORT_MARGIN = 8;
|
||||||
|
|
||||||
|
const SHORTCUT_HINT = {
|
||||||
|
paragraph: '',
|
||||||
|
heading_1: '#',
|
||||||
|
heading_2: '##',
|
||||||
|
heading_3: '###',
|
||||||
|
heading_4: '####',
|
||||||
|
heading_5: '#####',
|
||||||
|
heading_6: '######',
|
||||||
|
bullet_item: '-',
|
||||||
|
numbered_item: '1.',
|
||||||
|
quote: '>',
|
||||||
|
code: '```',
|
||||||
|
divider: '---',
|
||||||
|
};
|
||||||
|
import { listBlocks } from './blockRegistry.js';
|
||||||
|
|
||||||
|
// Menu flottant des commandes. Affiché ancré à un élément (anchorRect).
|
||||||
|
// La navigation clavier (↑ ↓ Enter Esc) est gérée par le composant parent
|
||||||
|
// via la méthode imperative move()/select() — au MVP on garde simple :
|
||||||
|
// le composant parent passe `query` et `selectedIndex` ; on déclenche
|
||||||
|
// `onSelect` au clic ou via Enter (intercepté côté parent).
|
||||||
|
|
||||||
|
function fuzzyScore(label, keywords, query) {
|
||||||
|
const q = query.toLowerCase().trim();
|
||||||
|
if (!q) return 1;
|
||||||
|
const haystack = [label, ...(keywords || [])].join(' ').toLowerCase();
|
||||||
|
if (haystack.includes(q)) return 2;
|
||||||
|
// Match partiel : tous les caractères dans l'ordre
|
||||||
|
let i = 0;
|
||||||
|
for (const c of haystack) {
|
||||||
|
if (c === q[i]) i++;
|
||||||
|
if (i === q.length) return 1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SlashMenu({
|
||||||
|
query = '',
|
||||||
|
anchorRect,
|
||||||
|
enabledBlocks,
|
||||||
|
selectedIndex,
|
||||||
|
onSelect,
|
||||||
|
onHoverIndex,
|
||||||
|
}) {
|
||||||
|
const allowed = useMemo(() => {
|
||||||
|
const all = listBlocks();
|
||||||
|
return enabledBlocks ? all.filter(b => enabledBlocks.includes(b.type)) : all;
|
||||||
|
}, [enabledBlocks]);
|
||||||
|
|
||||||
|
const items = useMemo(() => {
|
||||||
|
return allowed
|
||||||
|
.map(def => ({ def, score: fuzzyScore(def.label, def.keywords, query) }))
|
||||||
|
.filter(x => x.score > 0)
|
||||||
|
.sort((a, b) => b.score - a.score)
|
||||||
|
.map(x => x.def);
|
||||||
|
}, [allowed, query]);
|
||||||
|
|
||||||
|
const listRef = useRef(null);
|
||||||
|
const [position, setPosition] = useState({ top: 0, left: 0, maxHeight: MENU_MAX_HEIGHT });
|
||||||
|
|
||||||
|
// Scroll l'élément sélectionné dans la vue (interne au menu uniquement)
|
||||||
|
useEffect(() => {
|
||||||
|
const el = listRef.current?.querySelector(`[data-slash-index="${selectedIndex}"]`);
|
||||||
|
if (el) el.scrollIntoView({ block: 'nearest' });
|
||||||
|
}, [selectedIndex]);
|
||||||
|
|
||||||
|
// Positionnement adaptatif : flip au-dessus si pas assez de place en bas,
|
||||||
|
// clamp horizontalement, et limite la hauteur à l'espace disponible.
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!anchorRect || typeof window === 'undefined') return;
|
||||||
|
const vh = window.innerHeight;
|
||||||
|
const vw = window.innerWidth;
|
||||||
|
const spaceBelow = vh - anchorRect.bottom - VIEWPORT_MARGIN;
|
||||||
|
const spaceAbove = anchorRect.top - VIEWPORT_MARGIN;
|
||||||
|
|
||||||
|
let top;
|
||||||
|
let maxHeight;
|
||||||
|
if (spaceBelow >= Math.min(MENU_MAX_HEIGHT, 200) || spaceBelow >= spaceAbove) {
|
||||||
|
top = anchorRect.bottom + 6;
|
||||||
|
maxHeight = Math.max(120, Math.min(MENU_MAX_HEIGHT, spaceBelow - 6));
|
||||||
|
} else {
|
||||||
|
maxHeight = Math.max(120, Math.min(MENU_MAX_HEIGHT, spaceAbove - 6));
|
||||||
|
top = anchorRect.top - 6 - maxHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
let left = anchorRect.left;
|
||||||
|
if (left + MENU_WIDTH + VIEWPORT_MARGIN > vw) {
|
||||||
|
left = Math.max(VIEWPORT_MARGIN, vw - MENU_WIDTH - VIEWPORT_MARGIN);
|
||||||
|
}
|
||||||
|
if (left < VIEWPORT_MARGIN) left = VIEWPORT_MARGIN;
|
||||||
|
|
||||||
|
setPosition({ top, left, maxHeight });
|
||||||
|
}, [anchorRect, items.length]);
|
||||||
|
|
||||||
|
if (!anchorRect) return null;
|
||||||
|
|
||||||
|
const { top, left, maxHeight } = position;
|
||||||
|
|
||||||
|
if (items.length === 0) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed z-50 rounded-lg border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 shadow-md px-3 py-2 text-xs text-neutral-500"
|
||||||
|
style={{ top, left, width: MENU_WIDTH, maxHeight }}
|
||||||
|
>
|
||||||
|
Aucune commande pour « {query} »
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={listRef}
|
||||||
|
className="fixed z-50 overflow-y-auto rounded-lg border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 shadow-md py-1"
|
||||||
|
style={{ top, left, width: MENU_WIDTH, maxHeight }}
|
||||||
|
onMouseDown={(e) => e.preventDefault()} // ne pas voler le focus
|
||||||
|
>
|
||||||
|
<div className="px-3 pt-1.5 pb-1 text-[10px] font-medium uppercase tracking-wider text-neutral-400 dark:text-neutral-500">
|
||||||
|
Blocs de base
|
||||||
|
</div>
|
||||||
|
{items.map((def, i) => {
|
||||||
|
const active = i === selectedIndex;
|
||||||
|
const hint = SHORTCUT_HINT[def.type];
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={def.type}
|
||||||
|
type="button"
|
||||||
|
data-slash-index={i}
|
||||||
|
onMouseEnter={() => onHoverIndex?.(i)}
|
||||||
|
onClick={() => onSelect?.(def.type)}
|
||||||
|
className={`w-full flex items-center gap-3 px-2 py-1.5 text-left transition-colors ${active ? 'bg-neutral-100 dark:bg-neutral-800' : ''}`}
|
||||||
|
>
|
||||||
|
<span className="w-8 h-8 flex items-center justify-center rounded-md border border-neutral-200 dark:border-neutral-700 text-xs font-medium text-neutral-700 dark:text-neutral-300 flex-shrink-0">
|
||||||
|
{def.icon}
|
||||||
|
</span>
|
||||||
|
<span className="flex-1 min-w-0 truncate text-sm text-neutral-900 dark:text-white">
|
||||||
|
{def.label}
|
||||||
|
</span>
|
||||||
|
{hint && (
|
||||||
|
<span className="text-xs font-mono text-neutral-400 dark:text-neutral-500 flex-shrink-0">
|
||||||
|
{hint}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper exposé pour le parent : ordre des items pour navigation clavier.
|
||||||
|
export function getSlashItems(query, enabledBlocks) {
|
||||||
|
const all = listBlocks();
|
||||||
|
const allowed = enabledBlocks ? all.filter(b => enabledBlocks.includes(b.type)) : all;
|
||||||
|
return allowed
|
||||||
|
.map(def => ({ def, score: fuzzyScore(def.label, def.keywords, query) }))
|
||||||
|
.filter(x => x.score > 0)
|
||||||
|
.sort((a, b) => b.score - a.score)
|
||||||
|
.map(x => x.def);
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
// Registre extensible des types de blocs.
|
||||||
|
// Les blocs built-in s'enregistrent dans defaultBlocks.js (chargé par index.js).
|
||||||
|
// Les consommateurs peuvent appeler `registerBlock` pour ajouter leurs propres types.
|
||||||
|
//
|
||||||
|
// Forme d'une définition de bloc :
|
||||||
|
// {
|
||||||
|
// type: string, // id unique (ex: 'paragraph', 'my_custom')
|
||||||
|
// label: string, // libellé affiché dans le slash menu
|
||||||
|
// icon: string, // glyphe court (emoji ou caractère)
|
||||||
|
// keywords: string[], // termes de recherche pour le slash menu
|
||||||
|
// shortcut?: string, // préfixe markdown qui convertit (ex: '# ', '- ')
|
||||||
|
// shortcutTransform?: (block, match) => block, // optionnel : transforme un bloc existant
|
||||||
|
// create: (init?) => Block, // construit un nouveau bloc
|
||||||
|
// isText: boolean, // true si le bloc a un contentEditable de texte
|
||||||
|
// textTag?: string, // pour info / rendu en mode display
|
||||||
|
// textClassName?: string, // classes appliquées au contentEditable
|
||||||
|
// placeholder?: string, // texte fantôme quand le bloc est vide et focus
|
||||||
|
// renderPrefix?: (ctx) => ReactNode, // pour les listes (puce, numéro)
|
||||||
|
// Component?: ReactComponent, // pour blocs non-texte ; reçoit { block, onChange, disabled }
|
||||||
|
// }
|
||||||
|
|
||||||
|
const registry = new Map();
|
||||||
|
|
||||||
|
export function registerBlock(def) {
|
||||||
|
if (!def || typeof def.type !== 'string') {
|
||||||
|
throw new Error('registerBlock: `type` is required');
|
||||||
|
}
|
||||||
|
if (typeof def.create !== 'function') {
|
||||||
|
throw new Error(`registerBlock(${def.type}): \`create\` is required`);
|
||||||
|
}
|
||||||
|
registry.set(def.type, def);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBlockDef(type) {
|
||||||
|
return registry.get(type) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listBlocks() {
|
||||||
|
return Array.from(registry.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isBlockText(type) {
|
||||||
|
const def = registry.get(type);
|
||||||
|
return Boolean(def?.isText);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_BLOCK_TYPE = 'paragraph';
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { newBlockId } from '../utils/ids.js';
|
||||||
|
|
||||||
|
const BulletItem = {
|
||||||
|
type: 'bullet_item',
|
||||||
|
label: 'Liste à puces',
|
||||||
|
icon: '•',
|
||||||
|
keywords: ['liste', 'list', 'puce', 'bullet', 'ul'],
|
||||||
|
shortcut: '- ',
|
||||||
|
isText: true,
|
||||||
|
textTag: 'li',
|
||||||
|
textClassName: 'text-base leading-relaxed text-neutral-900 dark:text-white',
|
||||||
|
placeholder: 'Élément de liste',
|
||||||
|
renderPrefix() {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className="select-none mt-[0.55em] mr-2 inline-block w-1.5 h-1.5 rounded-full bg-neutral-700 dark:bg-neutral-300 flex-shrink-0"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
create(init = {}) {
|
||||||
|
return { id: newBlockId(), type: 'bullet_item', content: '', ...init };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BulletItem;
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { newBlockId } from '../utils/ids.js';
|
||||||
|
|
||||||
|
const Code = {
|
||||||
|
type: 'code',
|
||||||
|
label: 'Bloc de code',
|
||||||
|
icon: '</>',
|
||||||
|
keywords: ['code', 'pre', 'snippet'],
|
||||||
|
shortcut: '``` ',
|
||||||
|
isText: true,
|
||||||
|
disableShortcuts: true, // pas de markdown shortcut à l'intérieur
|
||||||
|
textTag: 'pre',
|
||||||
|
textClassName:
|
||||||
|
'block w-full font-mono text-sm whitespace-pre-wrap break-words rounded-lg bg-neutral-100 dark:bg-neutral-800/80 px-4 py-3 text-neutral-900 dark:text-neutral-100',
|
||||||
|
placeholder: 'Code…',
|
||||||
|
create(init = {}) {
|
||||||
|
return { id: newBlockId(), type: 'code', content: '', ...init };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Code;
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { newBlockId } from '../utils/ids.js';
|
||||||
|
|
||||||
|
function DividerComponent() {
|
||||||
|
return (
|
||||||
|
<div className="py-2">
|
||||||
|
<hr className="border-0 h-px bg-neutral-300 dark:bg-neutral-700" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const Divider = {
|
||||||
|
type: 'divider',
|
||||||
|
label: 'Séparateur',
|
||||||
|
icon: '—',
|
||||||
|
keywords: ['separateur', 'divider', 'hr', 'ligne', 'line'],
|
||||||
|
shortcut: '---',
|
||||||
|
isText: false,
|
||||||
|
create(init = {}) {
|
||||||
|
return { id: newBlockId(), type: 'divider', ...init };
|
||||||
|
},
|
||||||
|
Component: DividerComponent,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Divider;
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { newBlockId } from '../utils/ids.js';
|
||||||
|
|
||||||
|
const HEADING_STYLES = {
|
||||||
|
1: 'text-3xl font-bold leading-tight text-neutral-900 dark:text-white',
|
||||||
|
2: 'text-2xl font-bold leading-tight text-neutral-900 dark:text-white',
|
||||||
|
3: 'text-xl font-semibold leading-snug text-neutral-900 dark:text-white',
|
||||||
|
4: 'text-lg font-semibold leading-snug text-neutral-900 dark:text-white',
|
||||||
|
5: 'text-base font-semibold leading-normal text-neutral-900 dark:text-white',
|
||||||
|
6: 'text-sm font-semibold uppercase tracking-wide text-neutral-700 dark:text-neutral-300',
|
||||||
|
};
|
||||||
|
|
||||||
|
function makeHeading(level) {
|
||||||
|
return {
|
||||||
|
type: `heading_${level}`,
|
||||||
|
label: `Titre ${level}`,
|
||||||
|
icon: `H${level}`,
|
||||||
|
keywords: [`titre ${level}`, `heading ${level}`, `h${level}`],
|
||||||
|
isText: true,
|
||||||
|
textTag: `h${level}`,
|
||||||
|
textClassName: HEADING_STYLES[level],
|
||||||
|
placeholder: `Titre ${level}`,
|
||||||
|
shortcut: `${'#'.repeat(level)} `,
|
||||||
|
create(init = {}) {
|
||||||
|
return { id: newBlockId(), type: `heading_${level}`, content: '', ...init };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Heading1 = makeHeading(1);
|
||||||
|
export const Heading2 = makeHeading(2);
|
||||||
|
export const Heading3 = makeHeading(3);
|
||||||
|
export const Heading4 = makeHeading(4);
|
||||||
|
export const Heading5 = makeHeading(5);
|
||||||
|
export const Heading6 = makeHeading(6);
|
||||||
|
|
||||||
|
export default [Heading1, Heading2, Heading3, Heading4, Heading5, Heading6];
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { newBlockId } from '../utils/ids.js';
|
||||||
|
|
||||||
|
const NumberedItem = {
|
||||||
|
type: 'numbered_item',
|
||||||
|
label: 'Liste numérotée',
|
||||||
|
icon: '1.',
|
||||||
|
keywords: ['liste numerotee', 'numbered list', 'ordonnee', 'ordered', 'ol'],
|
||||||
|
shortcut: '1. ',
|
||||||
|
isText: true,
|
||||||
|
textTag: 'li',
|
||||||
|
textClassName: 'text-base leading-relaxed text-neutral-900 dark:text-white',
|
||||||
|
placeholder: 'Élément numéroté',
|
||||||
|
renderPrefix({ numberedIndex }) {
|
||||||
|
const n = typeof numberedIndex === 'number' ? numberedIndex : 1;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className="select-none mr-2 text-base leading-relaxed text-neutral-700 dark:text-neutral-300 flex-shrink-0 tabular-nums"
|
||||||
|
>
|
||||||
|
{n}.
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
create(init = {}) {
|
||||||
|
return { id: newBlockId(), type: 'numbered_item', content: '', ...init };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default NumberedItem;
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { newBlockId } from '../utils/ids.js';
|
||||||
|
|
||||||
|
const Paragraph = {
|
||||||
|
type: 'paragraph',
|
||||||
|
label: 'Texte',
|
||||||
|
icon: '¶',
|
||||||
|
keywords: ['paragraphe', 'paragraph', 'texte', 'text', 'p'],
|
||||||
|
isText: true,
|
||||||
|
textTag: 'p',
|
||||||
|
textClassName: 'text-base leading-relaxed text-neutral-900 dark:text-white',
|
||||||
|
placeholder: "Tapez '/' pour les commandes…",
|
||||||
|
create(init = {}) {
|
||||||
|
return { id: newBlockId(), type: 'paragraph', content: '', ...init };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Paragraph;
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { newBlockId } from '../utils/ids.js';
|
||||||
|
|
||||||
|
const Quote = {
|
||||||
|
type: 'quote',
|
||||||
|
label: 'Citation',
|
||||||
|
icon: '❝',
|
||||||
|
keywords: ['citation', 'quote', 'blockquote'],
|
||||||
|
shortcut: '> ',
|
||||||
|
isText: true,
|
||||||
|
textTag: 'blockquote',
|
||||||
|
textClassName:
|
||||||
|
'text-base leading-relaxed italic text-neutral-700 dark:text-neutral-300 border-l-4 border-neutral-300 dark:border-neutral-700 pl-4 py-1',
|
||||||
|
placeholder: 'Citation…',
|
||||||
|
create(init = {}) {
|
||||||
|
return { id: newBlockId(), type: 'quote', content: '', ...init };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Quote;
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
// Enregistrement des blocs built-in. Importé une seule fois depuis le barrel
|
||||||
|
// principal pour garantir que les types sont disponibles avant utilisation.
|
||||||
|
|
||||||
|
import { registerBlock } from '../blockRegistry.js';
|
||||||
|
import Paragraph from './Paragraph.js';
|
||||||
|
import HeadingList from './Heading.js';
|
||||||
|
import BulletItem from './BulletList.js';
|
||||||
|
import NumberedItem from './NumberedList.js';
|
||||||
|
import Quote from './Quote.js';
|
||||||
|
import Code from './Code.js';
|
||||||
|
import Divider from './Divider.js';
|
||||||
|
|
||||||
|
let registered = false;
|
||||||
|
|
||||||
|
export function registerBuiltInBlocks() {
|
||||||
|
if (registered) return;
|
||||||
|
registered = true;
|
||||||
|
registerBlock(Paragraph);
|
||||||
|
HeadingList.forEach(registerBlock);
|
||||||
|
registerBlock(BulletItem);
|
||||||
|
registerBlock(NumberedItem);
|
||||||
|
registerBlock(Quote);
|
||||||
|
registerBlock(Code);
|
||||||
|
registerBlock(Divider);
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
// Barrel public du BlockEditor.
|
||||||
|
//
|
||||||
|
// Importer le composant depuis :
|
||||||
|
// import { BlockEditor } from '@zen/core/shared/components';
|
||||||
|
//
|
||||||
|
// Pour enregistrer un type de bloc custom dans une app consommatrice :
|
||||||
|
// import { registerBlock } from '@zen/core/shared/components/BlockEditor';
|
||||||
|
// registerBlock({ type: 'kpi', label: 'KPI', icon: '📊', isText: false,
|
||||||
|
// create: () => ({ id: crypto.randomUUID(), type: 'kpi' }),
|
||||||
|
// Component: MyKpiBlock });
|
||||||
|
|
||||||
|
export { default as BlockEditor } from './BlockEditor.client.js';
|
||||||
|
export { default } from './BlockEditor.client.js';
|
||||||
|
export {
|
||||||
|
registerBlock,
|
||||||
|
getBlockDef,
|
||||||
|
listBlocks,
|
||||||
|
isBlockText,
|
||||||
|
DEFAULT_BLOCK_TYPE,
|
||||||
|
} from './blockRegistry.js';
|
||||||
|
export { newBlockId } from './utils/ids.js';
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
// Helpers de gestion du caret pour les contentEditable mono-bloc.
|
||||||
|
// On reste sur du texte brut au MVP : un seul Text node enfant (ou aucun).
|
||||||
|
|
||||||
|
export function getCaretOffset(el) {
|
||||||
|
const sel = typeof window !== 'undefined' ? window.getSelection() : null;
|
||||||
|
if (!sel || sel.rangeCount === 0) return 0;
|
||||||
|
const range = sel.getRangeAt(0);
|
||||||
|
if (!el.contains(range.startContainer)) return 0;
|
||||||
|
const pre = range.cloneRange();
|
||||||
|
pre.selectNodeContents(el);
|
||||||
|
pre.setEnd(range.startContainer, range.startOffset);
|
||||||
|
return pre.toString().length;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setCaretOffset(el, offset) {
|
||||||
|
if (!el) return;
|
||||||
|
el.focus();
|
||||||
|
const sel = window.getSelection();
|
||||||
|
if (!sel) return;
|
||||||
|
const range = document.createRange();
|
||||||
|
const text = el.firstChild;
|
||||||
|
if (!text) {
|
||||||
|
range.setStart(el, 0);
|
||||||
|
range.collapse(true);
|
||||||
|
} else {
|
||||||
|
const max = text.textContent?.length ?? 0;
|
||||||
|
const pos = Math.max(0, Math.min(offset, max));
|
||||||
|
range.setStart(text, pos);
|
||||||
|
range.collapse(true);
|
||||||
|
}
|
||||||
|
sel.removeAllRanges();
|
||||||
|
sel.addRange(range);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function focusEnd(el) {
|
||||||
|
if (!el) return;
|
||||||
|
const len = (el.textContent ?? '').length;
|
||||||
|
setCaretOffset(el, len);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isCaretAtStart(el) {
|
||||||
|
return getCaretOffset(el) === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isCaretAtEnd(el) {
|
||||||
|
return getCaretOffset(el) === (el.textContent ?? '').length;
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
// Génère un ID stable pour un bloc. randomUUID est dispo dans tous les navigateurs
|
||||||
|
// modernes côté client ; en SSR on retombe sur un fallback simple.
|
||||||
|
export function newBlockId() {
|
||||||
|
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||||
|
return crypto.randomUUID();
|
||||||
|
}
|
||||||
|
return `b_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
|
||||||
|
}
|
||||||
@@ -20,11 +20,11 @@ const Button = ({
|
|||||||
const variants = {
|
const variants = {
|
||||||
primary: 'bg-neutral-900 text-white hover:bg-neutral-800 focus:ring-neutral-900/20 dark:bg-white dark:text-black dark:hover:bg-neutral-100 dark:focus:ring-white/20',
|
primary: 'bg-neutral-900 text-white hover:bg-neutral-800 focus:ring-neutral-900/20 dark:bg-white dark:text-black dark:hover:bg-neutral-100 dark:focus:ring-white/20',
|
||||||
secondary: 'bg-transparent border border-neutral-300 text-neutral-700 hover:bg-neutral-100 focus:ring-neutral-500/20 dark:bg-neutral-800/60 dark:border-neutral-700/50 dark:text-white dark:hover:bg-neutral-800/80 dark:focus:ring-neutral-600/20',
|
secondary: 'bg-transparent border border-neutral-300 text-neutral-700 hover:bg-neutral-100 focus:ring-neutral-500/20 dark:bg-neutral-800/60 dark:border-neutral-700/50 dark:text-white dark:hover:bg-neutral-800/80 dark:focus:ring-neutral-600/20',
|
||||||
danger: 'bg-red-700/10 border border-red-800/30 text-red-700 hover:bg-red-700/15 focus:ring-red-700/20 dark:bg-red-700/20 dark:border-red-600/20 dark:text-red-600 dark:hover:bg-red-600/30 dark:focus:ring-red-600/20',
|
danger: 'bg-red-700/10 border border-red-800/30 text-red-700 hover:bg-red-700/15 focus:ring-red-700/20 dark:bg-red-700/10 dark:border-red-600/20 dark:text-red-600 dark:hover:bg-red-600/20 dark:focus:ring-red-600/20',
|
||||||
ghost: 'text-neutral-600 hover:text-neutral-900 hover:bg-neutral-100 focus:ring-neutral-500/20 dark:text-neutral-400 dark:hover:text-white dark:hover:bg-neutral-700/30 dark:focus:ring-neutral-600/20',
|
ghost: 'text-neutral-600 hover:text-neutral-900 hover:bg-neutral-100 focus:ring-neutral-500/20 dark:text-neutral-400 dark:hover:text-white dark:hover:bg-neutral-700/30 dark:focus:ring-neutral-600/20',
|
||||||
fullghost: 'text-neutral-600 hover:text-neutral-900 focus:[box-shadow:none] dark:text-neutral-400 dark:hover:text-white',
|
fullghost: 'text-neutral-600 hover:text-neutral-900 focus:[box-shadow:none] dark:text-neutral-400 dark:hover:text-white',
|
||||||
success: 'bg-green-700/10 border border-green-800/30 text-green-700 hover:bg-green-700/15 focus:ring-green-700/20 dark:bg-green-700/20 dark:border-green-600/20 dark:text-green-600 dark:hover:bg-green-600/30 dark:focus:ring-green-600/20',
|
success: 'bg-green-700/10 border border-green-800/30 text-green-700 hover:bg-green-700/15 focus:ring-green-700/20 dark:bg-green-700/10 dark:border-green-600/20 dark:text-green-600 dark:hover:bg-green-600/20 dark:focus:ring-green-600/20',
|
||||||
warning: 'bg-yellow-700/10 border border-yellow-800/30 text-yellow-700 hover:bg-yellow-700/15 focus:ring-yellow-700/20 dark:bg-yellow-700/20 dark:border-yellow-600/20 dark:text-yellow-600 dark:hover:bg-yellow-600/30 dark:focus:ring-yellow-600/20'
|
warning: 'bg-yellow-700/10 border border-yellow-800/30 text-yellow-700 hover:bg-yellow-700/15 focus:ring-yellow-700/20 dark:bg-yellow-700/10 dark:border-yellow-600/20 dark:text-yellow-600 dark:hover:bg-yellow-600/20 dark:focus:ring-yellow-600/20'
|
||||||
};
|
};
|
||||||
|
|
||||||
const sizes = {
|
const sizes = {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user