Compare commits
512 Commits
2b79abb351
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 6cb968c005 | |||
| e5b21c0d54 | |||
| e9a5750928 | |||
| 31d0359163 | |||
| 8c5c3baec4 | |||
| cb576f1036 | |||
| 3e9d1b22fd | |||
| fbcaed6816 | |||
| 2d76b56deb | |||
| 5ff1e0cd3c | |||
| c7ec34c560 | |||
| 66ced30d8f | |||
| 87e5889b76 | |||
| 8e37eb53ff | |||
| 90e172f571 | |||
| 56c334684f | |||
| 9723f40df2 | |||
| 3cc5a49518 | |||
| 1070bd7874 | |||
| fcb1a192ba | |||
| 73529b5caf | |||
| 621d1b48ee | |||
| c9f7b23498 | |||
| f5d627f324 | |||
| 67274687a3 | |||
| c3b54d9361 | |||
| d66b107636 | |||
| 83490de15d | |||
| 5ecbf13348 | |||
| b721574e58 | |||
| a1bcc4bfb9 | |||
| 688ae224ab | |||
| 7ac4caea23 | |||
| d983635491 | |||
| 543c4f5029 | |||
| d7e723770f | |||
| b54dce9445 | |||
| 42f1c47624 | |||
| ff10c2ffea | |||
| be5bdf15b7 | |||
| db468b56b5 | |||
| 3e90ef8c5d | |||
| 94a7bcf44d | |||
| 4a755d347c | |||
| 8159b5316a | |||
| 33ee62e908 | |||
| 9f328bc818 | |||
| 0941994e44 | |||
| 62cfb76d99 | |||
| 43d2328082 | |||
| 88e1840c8a | |||
| 0d45f18a0c | |||
| bbd12e7596 | |||
| 7b642d71b3 | |||
| d57d3a1ca1 | |||
| c9d41a8abe | |||
| cdd1e39c9a | |||
| 3fea89dbd4 | |||
| 649c69f408 | |||
| d170058509 | |||
| f4070f6611 | |||
| 8d8c773c00 | |||
| f1905a52cb | |||
| 8254e05202 | |||
| 3dc6c2a60e | |||
| 4afe334c6b | |||
| 6680551eee | |||
| 0b73ff1d04 | |||
| 615385b6b3 | |||
| 85d94fe135 | |||
| f9cf825648 | |||
| 8d51e9ba03 | |||
| f26a5bb9ec | |||
| d30c6b49fd | |||
| 5cd3b248ac | |||
| 668890fa7f | |||
| 491219f976 | |||
| 7412de96ea | |||
| b598ce7ed7 | |||
| a1069c3e3d | |||
| 6a73769d8e | |||
| 0fa20ace1e | |||
| 507e6b7d03 | |||
| 303042e749 | |||
| 452bd51d46 | |||
| a1f71860fe | |||
| e1ccd6ded9 | |||
| c32ab0909c | |||
| 2204cefabf | |||
| 63ba04d583 | |||
| c9609cb770 | |||
| e928e5317c | |||
| 56767cff0f | |||
| 9d8133c7f5 | |||
| 52d22e4171 | |||
| bde634d169 | |||
| 8b3baa39f8 | |||
| 53ace7fc1f | |||
| 515b95c8d3 | |||
| 332b7d31ef | |||
| 085a779c74 | |||
| 547b975c01 | |||
| 0c10dd0142 | |||
| 9670e03da8 | |||
| c87f74a18e | |||
| 20f31269e4 | |||
| 3d9431389b | |||
| 51cbf11729 | |||
| e26314b38d | |||
| 5a22eb5330 | |||
| f88fd15b71 | |||
| bd45507635 | |||
| 30cd0bbd81 | |||
| 741bf39a39 | |||
| ec83f87fd2 | |||
| b4bebfd1bd | |||
| d859874122 | |||
| 21634f5a38 | |||
| c7b96f2e16 | |||
| d225ff2e5f | |||
| 0000f22066 | |||
| 97ebaf0635 | |||
| 219fb36da1 | |||
| 3f93503996 | |||
| 9893ade233 | |||
| 2666d1a7fd | |||
| fdb36c39e5 | |||
| 2c132b3a8a | |||
| 7b6bf67f36 | |||
| d9fbe29031 | |||
| 5a8d2ad02f | |||
| 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 | |||
| 50f04f762b | |||
| 970092fccb | |||
| 345218641c | |||
| 183d151f0f | |||
| 27a9cbc12f | |||
| 77ca4fe66f | |||
| ba289d1a28 | |||
| b90b4e7bcc | |||
| 5743eb7f53 | |||
| 932e9b9373 | |||
| e27fe939c5 | |||
| 227ecc9e7d | |||
| f8ff34f815 | |||
| ecb4929753 | |||
| 04987e41f9 | |||
| af8c082463 | |||
| 342341e141 | |||
| 227b05a61e | |||
| 6ad16cddf9 | |||
| d0e407b67d | |||
| 6e794703ff | |||
| a92b4334f1 | |||
| 221836d91c | |||
| f60137011d | |||
| 4549299d50 | |||
| 3f5bbfda0b | |||
| bba1e9bc9a | |||
| ec0edf89b9 | |||
| f22fcb6f68 | |||
| c844bc5e86 | |||
| 661f6c0783 | |||
| 25f93526a5 | |||
| f413c4fa0f | |||
| 69bc05944c | |||
| b5d228b8ac | |||
| 70000e0761 | |||
| d6b7575444 | |||
| 48755c03f3 | |||
| f211946562 | |||
| b88f84e2a1 | |||
| 4b17852ace | |||
| 87990390c1 | |||
| 8b0041bd32 | |||
| cc0fe5aca7 | |||
| 6b95cdf535 | |||
| 66c862cf73 | |||
| f31b97cff4 | |||
| 995edae513 | |||
| c901e81c83 | |||
| 362804b650 | |||
| 45e664739e | |||
| 4b27c1efea | |||
| d3bde53762 | |||
| 472c6ebd7f | |||
| 13c263a7df | |||
| e4a0967203 | |||
| f7801c37d3 | |||
| f48f002fcd | |||
| 9f70d740ad | |||
| dbea58a978 | |||
| da2bd0b4e7 | |||
| 1aac03c2dc | |||
| e7aad33682 | |||
| ad4847e1c5 | |||
| fc0a4b744b | |||
| 189dcfc726 | |||
| 3e95387879 | |||
| bbb55605c3 | |||
| 7d6a13a57b | |||
| 68d97c81da | |||
| 6316ecd027 | |||
| f082ef4fda | |||
| e647aef47e | |||
| 3e7e0387a1 | |||
| 4f8dde1d21 | |||
| 3b04971483 | |||
| e8a3ecf86e | |||
| ccdd309414 | |||
| 739a0b2399 | |||
| fef71aaf92 | |||
| 766ee07ffc | |||
| e99970b9b2 | |||
| 44a811fd3c | |||
| 45ce8fda59 | |||
| 5f61d59777 | |||
| 5d4d1866b5 | |||
| 17e8961559 | |||
| b60514aebc | |||
| 5f93dda87d | |||
| 354cac3b27 | |||
| 4c5346fc5c | |||
| 18f1fcdbd0 | |||
| 482578af84 | |||
| ad7b907e57 | |||
| 324f81bb50 | |||
| 0dc6092780 | |||
| d0db9331f1 | |||
| 9ca3e0a83b | |||
| 68d427f2a2 | |||
| 173cea270f | |||
| 5398e2ac1d | |||
| e2dd60843f | |||
| 602b4f13cf | |||
| 0fd01d2b68 | |||
| 5f7288974f | |||
| 52f8ea2b13 | |||
| 7b76763741 | |||
| 869afbcb85 | |||
| cfbfbaaa3b | |||
| 312c8e0239 | |||
| 20612e3b48 | |||
| 456b1746bd | |||
| cc4527d488 | |||
| f3f7c7a011 | |||
| 35cfa8b51a | |||
| fa43e2c034 | |||
| 6cff764e2f | |||
| fd4c313bb8 | |||
| f45d295961 | |||
| 9bbfd4d319 | |||
| 4e56882dd4 | |||
| 8eb508574b | |||
| 0317a83ec6 | |||
| f9d4dce892 | |||
| b3e88989de | |||
| 3edc7267d8 | |||
| 843f992b1f | |||
| 18e0cb3486 | |||
| 7c92f34245 | |||
| c41172a397 | |||
| fa40565686 | |||
| 94aaeb241b | |||
| 51adae4e0f | |||
| 5feceb09f2 | |||
| aeab10c1d2 | |||
| 5fec68c1fc | |||
| 86bd97f50c | |||
| e5df0e102b | |||
| 52443591b2 | |||
| 2757f2edc5 | |||
| 84f03a2d79 | |||
| 5a8d1bb0ff | |||
| 3bd2e4bfba | |||
| cd91d40091 | |||
| 794e0866ec | |||
| 9bb1b398c7 | |||
| 0f42f202a2 | |||
| 5bd40dd19d | |||
| 0e9f70094d | |||
| bc63618190 | |||
| bf33754e74 | |||
| 43751ed0b5 | |||
| 2c781d4223 | |||
| ccb35c6420 | |||
| 60b3022a23 | |||
| 102d7acd40 | |||
| 2c02890216 | |||
| dbadd30837 | |||
| 866da94f06 | |||
| 3035d70d59 | |||
| db39e7b36a | |||
| 681de18d93 | |||
| 71fe05bd2b | |||
| da5a1c6587 | |||
| f54b2640ad | |||
| 16edecdc56 | |||
| 1bdabd8417 | |||
| 072ed33ca3 | |||
| f8ef884b63 | |||
| 32d6fca3ce | |||
| 5ab789667c | |||
| 4b0de4c724 | |||
| 0c860d9fe5 | |||
| cc5c556396 | |||
| 118f399208 | |||
| 868e07b394 | |||
| fe4ca228cc | |||
| e70499fa36 | |||
| 43fd0fb14f | |||
| 4bff03bfe6 | |||
| 3ead956571 | |||
| 8e8db997d6 | |||
| 4ec1a238f9 | |||
| 7633b151de | |||
| 834385fd56 | |||
| ab41ba9df5 | |||
| 18270540cc | |||
| 92265f450e | |||
| 1613bd5275 | |||
| 13410a6dd9 | |||
| 96c8352dcf | |||
| e00ec4bddb | |||
| 7ca818da5a | |||
| 7c85e54b26 | |||
| c3eeb3ca73 | |||
| b21f0e6b67 | |||
| f5c8dc842d | |||
| fd98f87e5a | |||
| d64423c1ad | |||
| 0106bc4ea0 | |||
| 61388f04a6 | |||
| 256df9102c | |||
| 40fc8a21e4 | |||
| c89f1cbbe9 | |||
| 12f66a2115 | |||
| 692f639cb5 | |||
| cdcd704d84 | |||
| 7cabdff799 | |||
| e00d6b3c42 | |||
| 17065292b8 | |||
| c08c0a622f | |||
| 7bd5aedb3b | |||
| 83672e9325 | |||
| dade434bd7 | |||
| 8752ce168e | |||
| 0eeeb9542f | |||
| f45322736f | |||
| 1bc8681b9e | |||
| 836134f701 | |||
| d8313cfeda | |||
| 384eadf7b7 | |||
| 1a46254221 | |||
| a85b3f1c1e | |||
| 59666807ae | |||
| e8d87b0a8f | |||
| 408653452c | |||
| 066b1eac0a | |||
| 2420a2cb1d | |||
| 99ef8b2326 | |||
| d6506eab5a | |||
| 0111e4b548 | |||
| d4e3b395e3 | |||
| 138183f3a8 | |||
| 4d84669f9f | |||
| 237a49a997 | |||
| e2d688c47a | |||
| 345371d43c | |||
| ba3b6239b1 | |||
| 88c04045d2 | |||
| 6b71818531 | |||
| 2b9a33f37c | |||
| f2ddb0f413 | |||
| 9b44b3a6af | |||
| e881f04ca2 | |||
| a6a681e358 | |||
| f387511c40 | |||
| d855485ef1 | |||
| 3a339d07da | |||
| dcd4d9b9f9 | |||
| 10660bedf5 | |||
| f08376d979 | |||
| af8da2aa86 | |||
| 5132d0b52a | |||
| 692a014dd8 | |||
| b49cddece3 | |||
| 5325b80c05 | |||
| 962d4c5008 | |||
| fc7d4ffe1f | |||
| 91dff122c4 | |||
| c0a62fe87a | |||
| 0eee13542d | |||
| 1c6eb0c818 | |||
| 41edccc1a3 | |||
| 371a69c499 | |||
| 73a8639324 | |||
| c7018848a1 | |||
| 1d64ffd6f5 | |||
| 8b8accdfce | |||
| 44570eb773 | |||
| 2f5cf9fe22 | |||
| a3cb55814f |
+13
-12
@@ -10,6 +10,9 @@ ZEN_CURRENCY=CAD
|
||||
ZEN_CURRENCY_SYMBOL=$
|
||||
ZEN_SUPPORT_EMAIL=support@exemple.com
|
||||
|
||||
# PROXY (activer si derrière un reverse proxy)
|
||||
ZEN_TRUST_PROXY=false
|
||||
|
||||
# DATABASE
|
||||
ZEN_DATABASE_URL=postgres://USER:PASSWORD@HOST:PORT/postgres
|
||||
ZEN_DATABASE_URL_DEV=postgres://USER:PASSWORD@HOST:PORT/postgres_dev
|
||||
@@ -18,23 +21,15 @@ ZEN_DB_SSL_DISABLED=false
|
||||
# STORAGE
|
||||
# Fournisseur : 'r2' (défaut) ou 'backblaze'
|
||||
ZEN_STORAGE_PROVIDER=r2
|
||||
|
||||
# Cloudflare R2 (ZEN_STORAGE_PROVIDER=r2)
|
||||
# Endpoint format : <accountId>.r2.cloudflarestorage.com
|
||||
# R2 endpoint : <accountId>.r2.cloudflarestorage.com
|
||||
# Backblaze endpoint : s3.<region>.backblazeb2.com
|
||||
# REGION optionnelle pour R2 (défaut : auto), obligatoire pour Backblaze
|
||||
ZEN_STORAGE_ENDPOINT=
|
||||
ZEN_STORAGE_REGION=auto
|
||||
ZEN_STORAGE_REGION=
|
||||
ZEN_STORAGE_BUCKET=
|
||||
ZEN_STORAGE_ACCESS_KEY=
|
||||
ZEN_STORAGE_SECRET_KEY=
|
||||
|
||||
# Backblaze B2 (ZEN_STORAGE_PROVIDER=backblaze)
|
||||
# Endpoint format : s3.<region>.backblazeb2.com
|
||||
ZEN_STORAGE_B2_ENDPOINT=
|
||||
ZEN_STORAGE_B2_REGION=
|
||||
ZEN_STORAGE_B2_BUCKET=
|
||||
ZEN_STORAGE_B2_ACCESS_KEY=
|
||||
ZEN_STORAGE_B2_SECRET_KEY=
|
||||
|
||||
# EMAIL
|
||||
ZEN_EMAIL_RESEND_APIKEY=
|
||||
ZEN_EMAIL_FROM_NAME="EXEMPLE"
|
||||
@@ -56,5 +51,11 @@ ZEN_PUBLIC_LOGO_WHITE=
|
||||
ZEN_PUBLIC_LOGO_BLACK=
|
||||
ZEN_PUBLIC_LOGO_URL=
|
||||
|
||||
# MEDIA
|
||||
ZEN_MEDIA=false
|
||||
|
||||
# OTHERS
|
||||
NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
# DEVKIT (developer tools)
|
||||
ZEN_DEVKIT=false
|
||||
@@ -1,3 +1,13 @@
|
||||
# Claude Code Rules
|
||||
|
||||
Always read [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.
|
||||
|
||||
-387
@@ -1,387 +0,0 @@
|
||||
# ZEN — Plan du projet
|
||||
|
||||
> Un CMS Next.js construit sur l'essentiel, rien de plus, rien de moins.
|
||||
|
||||
ZEN est un système de gestion de contenu (CMS) pour Next.js. Il s'installe automatiquement dans n'importe quel projet Next.js via :
|
||||
|
||||
```bash
|
||||
npx @zen/start
|
||||
```
|
||||
|
||||
Le package principal est `@zen/core`. Il fournit toute l'infrastructure nécessaire pour gérer un site : authentification, base de données, stockage, courriels, paiements, PDF, tâches planifiées, notifications. Chaque fonctionnalité est indépendante — les cores ne se connaissent pas entre eux, mais le reste du CMS s'appuie sur chacun d'eux.
|
||||
|
||||
---
|
||||
|
||||
## Principes directeurs
|
||||
|
||||
**Core purity** — Chaque core contient uniquement du code propre à son domaine. Aucune logique métier, aucune dépendance vers un autre core.
|
||||
|
||||
**Minimal by default** — Pas de fonctionnalité superflue. Si ce n'est pas nécessaire, ça n'existe pas.
|
||||
|
||||
**Sécuritaire par défaut** — Requêtes paramétrées, protection CSRF, limitation de débit, validation en entrée, erreurs opaques vers le client.
|
||||
|
||||
**Performant** — Connexions en pool, cache HTTP, génération différée des services.
|
||||
|
||||
---
|
||||
|
||||
## Structure du projet
|
||||
|
||||
```
|
||||
src/
|
||||
core/ # Infrastructure fondamentale — la base de tout
|
||||
features/ # Fonctionnalités du CMS utilisant les cores
|
||||
shared/ # Utilitaires, composants et styles partagés
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `src/core` — Les piliers du CMS
|
||||
|
||||
Chaque core est une brique indépendante. Il n'existe qu'une seule façon de faire chaque chose dans ZEN : passer par le core concerné. L'ensemble du CMS, des features aux modules, repose sur ces cores.
|
||||
|
||||
---
|
||||
|
||||
### API
|
||||
|
||||
**`@zen/core/api`**
|
||||
|
||||
L'API est le point d'entrée unique de toutes les requêtes HTTP du CMS. Que ce soit depuis l'Admin, le front-end du site ou un module tiers, tout passe par là. Il n'existe aucune autre route API dans ZEN.
|
||||
|
||||
Toutes les requêtes arrivent via le catch-all Next.js `app/zen/api/[...path]/route.js`. Le router les dispatche vers le bon handler selon le chemin et la méthode HTTP.
|
||||
|
||||
**Ce qu'il fait :**
|
||||
- Routage dynamique avec paramètres nommés (`:id`) et wildcards (`/**`)
|
||||
- Protection CSRF automatique sur toutes les requêtes mutantes (POST, PUT, PATCH, DELETE)
|
||||
- Limitation de débit par IP avec des préréglages par action (login, register, api)
|
||||
- Injection de session dans le contexte de chaque handler
|
||||
- Trois niveaux d'accès : `public`, `user` (session requise), `admin` (rôle admin requis)
|
||||
- Enregistrement dynamique des routes par les features
|
||||
- Réponses standardisées via `apiSuccess()` et `apiError()`
|
||||
|
||||
**Règle absolue :** Toute route API du CMS doit être enregistrée dans ce router. Jamais de `route.js` parallèle.
|
||||
|
||||
---
|
||||
|
||||
### Database
|
||||
|
||||
**`@zen/core/database`**
|
||||
|
||||
La couche d'accès à la base de données PostgreSQL. Toute l'application communique avec la base de données uniquement via ce core — jamais directement.
|
||||
|
||||
**Ce qu'il fait :**
|
||||
- Pool de connexions PostgreSQL (max 20 clients, reconnexion automatique)
|
||||
- Fonctions de requête de bas niveau : `query()`, `queryOne()`, `queryAll()`, `transaction()`
|
||||
- Helpers CRUD complets : `create()`, `find()`, `findOne()`, `findById()`, `update()`, `updateById()`, `delete()`, `deleteWhere()`, `count()`, `exists()`
|
||||
- Protection contre l'injection SQL : requêtes paramétrées `$1, $2, ...`, jamais de concaténation
|
||||
- Contrôle d'accès aux colonnes via liste blanche (`allowedColumns`) — empêche le mass assignment
|
||||
- Validation et échappement des identifiants SQL (noms de tables et colonnes)
|
||||
- Gestion SSL configurable : vérification complète en production, souple en développement
|
||||
- Erreurs opaques : seul le code SQLSTATE est exposé, jamais les détails internes
|
||||
|
||||
**Règle absolue :** Aucun code ailleurs dans le CMS ne communique directement avec la base de données. Tout passe par ce core.
|
||||
|
||||
---
|
||||
|
||||
### Email
|
||||
|
||||
**`@zen/core/email`**
|
||||
|
||||
L'unique système d'envoi de courriels du CMS. Alimenté par Resend. Toute l'application envoie ses courriels via ce core.
|
||||
|
||||
**Ce qu'il fait :**
|
||||
- Envoi de courriels simples : `sendEmail({ to, subject, html })`
|
||||
- Envoi en lot : `sendBatchEmails(emailArray)`
|
||||
- Expéditeur configurable (adresse, nom d'affichage)
|
||||
- Template de base React Email (`BaseLayout`) : logo, marque, pied de page, lien de support
|
||||
- Réponses standardisées `{ success, data, error }`
|
||||
|
||||
**Templates fournis :**
|
||||
- `BaseLayout` — Enveloppe visuelle commune à tous les courriels du CMS (logo, couleurs, pied de page)
|
||||
|
||||
**Règle absolue :** Tout courriel envoyé par le CMS passe par ce core.
|
||||
|
||||
---
|
||||
|
||||
### Cron
|
||||
|
||||
**`@zen/core/cron`**
|
||||
|
||||
Le registre central de toutes les tâches planifiées. Chaque tâche récurrente de l'application s'enregistre ici — jamais en dehors.
|
||||
|
||||
**Ce qu'il fait :**
|
||||
- Planification de tâches avec expressions cron standard (`schedule(name, expression, handler)`)
|
||||
- Support des fuseaux horaires (défaut : `ZEN_TIMEZONE`)
|
||||
- Survie aux hot reloads Next.js via stockage global (`Symbol.for`)
|
||||
- Remplacement automatique si une tâche du même nom est enregistrée deux fois
|
||||
- Déclenchement manuel : `trigger(name)` pour exécuter une tâche immédiatement
|
||||
- Introspection : `getJobs()`, `getStatus()`, `isRunning(name)`
|
||||
- Gestion des erreurs : un échec de tâche ne plante pas le scheduler
|
||||
|
||||
**Règle absolue :** Toutes les tâches cron de l'application sont enregistrées via ce core.
|
||||
|
||||
---
|
||||
|
||||
### Storage
|
||||
|
||||
**`@zen/core/storage`**
|
||||
|
||||
La gestion complète du stockage de fichiers, compatible S3 (Cloudflare R2 ou Backblaze B2). Toute l'application lit et écrit des fichiers via ce core.
|
||||
|
||||
**Ce qu'il fait :**
|
||||
- Upload, téléchargement, suppression, copie et déplacement de fichiers
|
||||
- Upload d'images avec cache longue durée (`max-age=31536000`)
|
||||
- Suppression en lot optimisée (S3 batch delete)
|
||||
- URLs pré-signées pour accès direct (GET ou PUT)
|
||||
- Liste paginée des fichiers avec préfixe et continuation token
|
||||
- Signature AWS Signature V4 pour toutes les requêtes
|
||||
- Protection contre le path traversal (`..`, `.`, segments vides, null bytes)
|
||||
- Contrôle d'accès via policies : préfixes publics vs chemins protégés (session + rôle)
|
||||
- Headers de sécurité : `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`
|
||||
- Téléchargement forcé pour les fichiers non-images (prévient l'exécution en navigateur)
|
||||
- Utilitaires : validation de type, validation de taille, nommage unique, extension, MIME type
|
||||
|
||||
**Règle absolue :** Tout accès fichier passe par ce core.
|
||||
|
||||
---
|
||||
|
||||
### Payments
|
||||
|
||||
**`@zen/core/payments`**
|
||||
|
||||
L'intégration Stripe pour les paiements. Tout ce qui touche à la facturation ou aux transactions dans l'application passe par ce core.
|
||||
|
||||
**Ce qu'il fait :**
|
||||
- Sessions de checkout Stripe (`createCheckoutSession`)
|
||||
- PaymentIntents pour paiements personnalisés (`createPaymentIntent`)
|
||||
- Gestion des clients Stripe (`createCustomer`, `getOrCreateCustomer`)
|
||||
- Récupération des sessions et intentions de paiement
|
||||
- Listing des moyens de paiement d'un client
|
||||
- Vérification des webhooks Stripe (`verifyWebhookSignature`)
|
||||
- Remboursements (`createRefund`)
|
||||
- Initialisation paresseuse du client Stripe (seulement si configuré)
|
||||
- Clé publiable exposée pour le front-end (`getPublishableKey`)
|
||||
|
||||
---
|
||||
|
||||
### PDF
|
||||
|
||||
**`@zen/core/pdf`**
|
||||
|
||||
La génération de fichiers PDF à partir de composants React. Tout PDF produit par l'application passe par ce core.
|
||||
|
||||
**Ce qu'il fait :**
|
||||
- Rendu de documents PDF depuis des composants React (`renderToBuffer`)
|
||||
- Réexporte l'API complète de `@react-pdf/renderer` : `Document`, `Page`, `View`, `Text`, `Image`, `Link`, `StyleSheet`, `Font`
|
||||
- Utilitaire de nommage : `getFilename(prefix, identifier, date?)` → `"invoice-12345-2024-01-15.pdf"`
|
||||
|
||||
---
|
||||
|
||||
### Toast
|
||||
|
||||
**`@zen/core/toast`**
|
||||
|
||||
Le système de notifications visuelles de l'application. Que ce soit dans l'Admin ou sur le front-end, c'est l'unique système de toast à utiliser.
|
||||
|
||||
**Ce qu'il fait :**
|
||||
- Contexte React via `<ToastProvider>` et `useToast()`
|
||||
- Quatre types de notifications : `success`, `error`, `warning`, `info`
|
||||
- Disparition automatique avec durées configurables par type
|
||||
- Animation de sortie (fade-out 300ms)
|
||||
- Flag `dismissible` par notification
|
||||
- `<ToastContainer>` pour le rendu dans l'arbre React
|
||||
|
||||
**Règle absolue :** Un seul système de toast dans toute l'application — celui-ci.
|
||||
|
||||
---
|
||||
|
||||
## `src/features` — Les fonctionnalités du CMS
|
||||
|
||||
Les features utilisent les cores pour implémenter les fonctionnalités centrales du CMS. Elles ont accès à l'API, à la base de données, aux courriels, etc.
|
||||
|
||||
---
|
||||
|
||||
### Auth
|
||||
|
||||
**`@zen/core/auth`**
|
||||
|
||||
Le système d'authentification du CMS. Toute authentification d'utilisateur dans le site passe par ici.
|
||||
|
||||
**Ce qu'il fait :**
|
||||
- Inscription et connexion d'utilisateurs
|
||||
- Hachage des mots de passe avec `scrypt` (natif Node.js) + sel aléatoire + comparaison résistante aux timing attacks
|
||||
- Gestion de sessions : création, validation, suppression, rafraîchissement automatique (<20 jours → 30 jours)
|
||||
- Vérification d'adresse courriel par token
|
||||
- Réinitialisation de mot de passe par lien sécurisé
|
||||
- Middleware de protection de routes : `protect()`, `checkAuth()`, `requireRole()`
|
||||
- Server Actions : `loginAction`, `registerAction`, `logoutAction`, `forgotPasswordAction`, `resetPasswordAction`, `verifyEmailAction`
|
||||
- Tables gérées : `zen_auth_users`, `zen_auth_sessions`, `zen_auth_email_verifications`, `zen_auth_password_resets`
|
||||
|
||||
**Règle absolue :** Toute authentification de site passe par cette feature.
|
||||
|
||||
---
|
||||
|
||||
### Admin
|
||||
|
||||
**`@zen/core/admin`**
|
||||
|
||||
L'interface d'administration centrale. Tableau de bord visuel pour gérer le site et ses modules.
|
||||
|
||||
**Ce qu'il fait :**
|
||||
- Protection des routes admin : `protectAdmin()`, `isAdmin()`
|
||||
- Pages catch-all pour l'interface admin (`AdminPagesClient`, `AdminPagesLayout`)
|
||||
- Navigation construite côté serveur (`buildNavigationSections`)
|
||||
- Gestion des utilisateurs depuis l'interface
|
||||
|
||||
**Navigation :**
|
||||
- Tableau de bord → `/admin/dashboard`
|
||||
- Utilisateurs → `/admin/users`
|
||||
|
||||
---
|
||||
|
||||
### Provider
|
||||
|
||||
**`@zen/core/provider`**
|
||||
|
||||
Le provider React racine du CMS. Il s'insère dans le layout du site et active tout ce dont le CMS a besoin côté client.
|
||||
|
||||
**Ce qu'il fait :**
|
||||
- Enveloppe l'application dans `<ToastProvider>` avec son `<ToastContainer>`
|
||||
- Un seul composant à poser dans le layout : `<ZenProvider>`
|
||||
|
||||
---
|
||||
|
||||
## `src/shared` — Utilitaires partagés
|
||||
|
||||
Tout ce qui est utile à travers le CMS sans appartenir à un core ou une feature.
|
||||
|
||||
### Composants UI (`src/shared/components/`)
|
||||
|
||||
Bibliothèque de composants React stylisés, utilisés dans l'Admin et les pages du CMS :
|
||||
|
||||
`Badge`, `StatusBadge`, `TypeBadge`, `Button`, `Card`, `Input`, `Loading`, `LoadingState`, `Modal`, `Pagination`, `Select`, `StatCard`, `Table`, `Textarea`, `MarkdownEditor`, `PasswordStrengthIndicator`, `FilterTabs`, `Breadcrumb`
|
||||
|
||||
### Utilitaires (`src/shared/lib/`, `src/shared/utils/`)
|
||||
|
||||
- **`appConfig`** — Lecture centralisée de la configuration (`getAppName`, `getAppConfig`, `getPublicBaseUrl`)
|
||||
- **`logger`** — Console stylisée pour les logs (`step`, `done`, `warn`, `fail`, `info`)
|
||||
- **`dates`** — Manipulation de dates en UTC (`formatDateForDisplay`, `getDaysBetween`, `isOverdue`, etc.)
|
||||
- **`metadata`** — Génération de métadonnées Next.js (`generateMetadata`, `generateTitle`, `generateRobots`)
|
||||
- **`rateLimit`** — Limitation de débit partagée (`checkRateLimit`) avec préréglages par action
|
||||
- **`currency`** — Formatage monétaire (`formatCurrency`, `getCurrencySymbol`)
|
||||
|
||||
### Icons (`src/shared/Icons.js`)
|
||||
|
||||
Bibliothèque de plus de 1000 icônes (style Untitled UI).
|
||||
|
||||
### Styles (`src/shared/styles/zen.css`)
|
||||
|
||||
Feuille de style CSS de base du CMS.
|
||||
|
||||
---
|
||||
|
||||
## Initialisation
|
||||
|
||||
Dans `instrumentation.js` du projet Next.js :
|
||||
|
||||
```js
|
||||
export async function register() {
|
||||
if (process.env.NEXT_RUNTIME === 'nodejs') {
|
||||
const { initializeZen } = await import('@zen/core');
|
||||
await initializeZen();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Dans `app/layout.js` :
|
||||
|
||||
```jsx
|
||||
import { ZenProvider } from '@zen/core/provider';
|
||||
|
||||
export default function RootLayout({ children }) {
|
||||
return (
|
||||
<html>
|
||||
<body>
|
||||
<ZenProvider>
|
||||
{children}
|
||||
</ZenProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Dans `app/zen/api/[...path]/route.js` :
|
||||
|
||||
```js
|
||||
export { GET, POST, PUT, PATCH, DELETE } from '@zen/core/zen/api';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Variables d'environnement
|
||||
|
||||
| Variable | Description |
|
||||
|---|---|
|
||||
| `ZEN_NAME` | Nom de l'application |
|
||||
| `ZEN_TIMEZONE` | Fuseau horaire IANA (défaut : `America/Toronto`) |
|
||||
| `ZEN_CURRENCY` | Code monétaire (défaut : `CAD`) |
|
||||
| `ZEN_CURRENCY_SYMBOL` | Symbole monétaire (défaut : `$`) |
|
||||
| `NEXT_PUBLIC_URL` | URL de base (production) |
|
||||
| `NEXT_PUBLIC_URL_DEV` | URL de base (développement) |
|
||||
| `ZEN_DATABASE_URL` | Chaîne de connexion PostgreSQL (production) |
|
||||
| `ZEN_DATABASE_URL_DEV` | Chaîne de connexion PostgreSQL (développement) |
|
||||
| `ZEN_DB_SSL_DISABLED` | Désactiver TLS (local uniquement) |
|
||||
| `ZEN_EMAIL_RESEND_APIKEY` | Clé API Resend |
|
||||
| `ZEN_EMAIL_FROM_ADDRESS` | Adresse d'expédition |
|
||||
| `ZEN_EMAIL_FROM_NAME` | Nom d'affichage de l'expéditeur |
|
||||
| `ZEN_STORAGE_PROVIDER` | `r2` ou `backblaze` |
|
||||
| `ZEN_STORAGE_ENDPOINT` | Endpoint S3-compatible |
|
||||
| `ZEN_STORAGE_ACCESS_KEY` | Clé d'accès stockage |
|
||||
| `ZEN_STORAGE_SECRET_KEY` | Clé secrète stockage |
|
||||
| `ZEN_STORAGE_BUCKET` | Nom du bucket |
|
||||
| `STRIPE_SECRET_KEY` | Clé secrète Stripe |
|
||||
| `STRIPE_PUBLISHABLE_KEY` | Clé publiable Stripe |
|
||||
| `STRIPE_WEBHOOK_SECRET` | Secret webhook Stripe |
|
||||
|
||||
---
|
||||
|
||||
## CLI base de données
|
||||
|
||||
```bash
|
||||
npx zen-db init # Créer toutes les tables
|
||||
npx zen-db test # Tester la connexion
|
||||
npx zen-db drop # Supprimer toutes les tables (confirmation requise)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Flux d'une requête
|
||||
|
||||
```
|
||||
Navigateur / Client
|
||||
↓
|
||||
app/zen/api/[...path]/route.js ← catch-all Next.js
|
||||
↓
|
||||
core/api — router.js ← CSRF, rate limit, auth
|
||||
↓
|
||||
Handler (feature) ← logique métier
|
||||
↓
|
||||
core/database, core/storage, ← accès aux ressources
|
||||
core/email, core/payments…
|
||||
↓
|
||||
Réponse standardisée ← apiSuccess() / apiError()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Résumé des règles absolues
|
||||
|
||||
| Domaine | Règle |
|
||||
|---|---|
|
||||
| API | Toutes les routes HTTP passent par `core/api`. Aucune autre route API. |
|
||||
| Base de données | Tout accès DB passe par `core/database`. Jamais de requêtes directes. |
|
||||
| Courriels | Tout envoi de courriel passe par `core/email`. |
|
||||
| Cron | Toutes les tâches planifiées s'enregistrent dans `core/cron`. |
|
||||
| Stockage | Tout accès fichier passe par `core/storage`. |
|
||||
| Notifications | Un seul système de toast dans toute l'app : `core/toast`. |
|
||||
| Authentification | Toute auth de site passe par `features/auth`. |
|
||||
@@ -1,6 +1,6 @@
|
||||
# ZEN
|
||||
|
||||
Un CMS Next.js construit sur l'essentiel, rien de plus, rien de moins.
|
||||
Une plateforme multi-usage construite sur l'essentiel, rien de plus, rien de moins.
|
||||
|
||||

|
||||
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
# TODO
|
||||
|
||||
- [ ] Thème : ajouter un core de thème avec support auto, clair et sombre
|
||||
- [ ] Post types : ajouter une feature de types de posts, inspirée de l'ancien module
|
||||
- [ ] Pages : ajouter une feature de pages pour créer des pages directement dans le CMS
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 507 KiB After Width: | Height: | Size: 484 KiB |
+285
@@ -0,0 +1,285 @@
|
||||
# ZEN — Design Language
|
||||
|
||||
Document de référence visuelle. Décrit l'apparence, le ressenti et les règles visuelles du produit. Valable pour toute reproduction, quelle que soit la technologie.
|
||||
|
||||
---
|
||||
|
||||
## Philosophie
|
||||
|
||||
ZEN est un outil de travail. Pas un produit marketing. L'interface disparaît pour laisser place au contenu.
|
||||
|
||||
**Un seul principe directeur : ne jamais ajouter un élément sans raison.**
|
||||
|
||||
Chaque couleur, bordure, espace ou texte supplémentaire doit justifier sa présence. Le doute se résout toujours en faveur de la suppression.
|
||||
|
||||
---
|
||||
|
||||
## Palette
|
||||
|
||||
### Base
|
||||
|
||||
Le produit est **noir et blanc**. Pas de couleurs de marque, pas de teintes bleues ou violettes dans les fonds. Le fond est blanc pur. Le texte est presque noir.
|
||||
|
||||
### Accents sémantiques
|
||||
|
||||
Quatre couleurs uniquement. Utilisées **avec grande parcimonie** — pour les statuts, les alertes, les deltas. Jamais comme couleur décorative. Jamais sur un fond.
|
||||
|
||||
| Couleur | Usage |
|
||||
|---|---|
|
||||
| **Bleu** | Information, liens, action principale |
|
||||
| **Vert** | Succès, actif, delta positif |
|
||||
| **Ambre** | Avertissement, en attente, brouillon |
|
||||
| **Rouge** | Erreur, danger, suppression, delta négatif |
|
||||
|
||||
Chaque couleur existe en trois déclinaisons : la couleur elle-même (pour le texte), un fond très pâle, et une bordure légère. Ces trois valeurs forment toujours un ensemble — on ne mélange pas les déclinaisons de couleurs différentes.
|
||||
|
||||
### Ce qu'on n'utilise pas
|
||||
|
||||
- Dégradés
|
||||
- Couleurs de fond dans la sidebar ou les sections (sauf hover/actif)
|
||||
- Couleurs de marque personnalisées
|
||||
- Transparence et flou (sauf l'overlay des modales)
|
||||
|
||||
---
|
||||
|
||||
## Typographie
|
||||
|
||||
**Police unique : IBM Plex Sans.** Pour les codes, IDs, montants et dates : IBM Plex Mono.
|
||||
|
||||
### Hiérarchie
|
||||
|
||||
| Rôle | Taille | Graisse | Notes |
|
||||
|---|---|---|---|
|
||||
| Titre de page | 20px | 600 | |
|
||||
| Titre de section | 14–20px | 600 | |
|
||||
| Corps de texte | 13px | 400 | |
|
||||
| Texte secondaire | 12px | 400 | Dans les tableaux, les listes |
|
||||
| Labels / captions | 12px | 400–500 | |
|
||||
| Labels colonnes | 11px | 500 | Uppercase, lettre-espacement +0.04em |
|
||||
| Codes / IDs / montants | 11–13px | 400 | IBM Plex Mono |
|
||||
|
||||
**Taille minimale : 11px.** En dessous, on ne descend pas.
|
||||
|
||||
### Règles d'écriture
|
||||
|
||||
- **Sentence case partout.** "Tableau de bord", pas "TABLEAU DE BORD".
|
||||
- Seule exception : les en-têtes de colonnes de tableau, en majuscules petits.
|
||||
- Texte court et direct. Pas de phrase là où un mot suffit.
|
||||
- Les montants financiers, les dates ISO et les identifiants sont toujours en monospace.
|
||||
|
||||
---
|
||||
|
||||
## Espace
|
||||
|
||||
Base : **4px**. Tout espacement est un multiple de 4.
|
||||
|
||||
| Valeur | Usage typique |
|
||||
|---|---|
|
||||
| 4px | Espacement interne minimal (gap entre icône et texte) |
|
||||
| 8px | Padding compact, gap entre éléments proches |
|
||||
| 12px | Padding interne des petits composants |
|
||||
| 16px | Padding standard des cartes et panneaux |
|
||||
| 24px | Espacement entre sections |
|
||||
| 32px | Padding latéral du contenu principal |
|
||||
| 48–64px | Espacement entre blocs majeurs |
|
||||
|
||||
La densité est **moyenne**. Pas étouffant, pas aéré. Un admin est un outil qu'on utilise plusieurs heures par jour — la densité confortable prime sur l'esthétique aérée.
|
||||
|
||||
---
|
||||
|
||||
## Formes et bordures
|
||||
|
||||
### Rayon de bordure
|
||||
|
||||
| Contexte | Rayon |
|
||||
|---|---|
|
||||
| Badges, étiquettes, petits éléments | Complet |
|
||||
| Boutons, champs de saisie | 8px |
|
||||
| Modales, panneaux flottants importants, tableau, cartes | 12px |
|
||||
| Avatars, indicateurs ronds | Cercle complet |
|
||||
|
||||
**12px est le rayon standard.** On ne mélange pas les rayons dans un même composant.
|
||||
|
||||
### Bordures
|
||||
|
||||
Toutes les séparations sont des **bordures 1px solid**. Pas d'ombres sur les cartes et surfaces statiques. Pas de lignes de 2px ou plus.
|
||||
|
||||
Les ombres existent uniquement pour les éléments qui **flottent au-dessus** de l'interface : menus déroulants, modales, toasts. Elles sont discrètes — juste assez pour indiquer la profondeur.
|
||||
|
||||
---
|
||||
|
||||
## Structure de l'admin
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ Sidebar 230px │ Barre supérieure 48px │
|
||||
│ ├─────────────────────────── │
|
||||
│ Logo │ │
|
||||
│ │ Zone de contenu │
|
||||
│ Nav principale │ padding 28px / 32px │
|
||||
│ │ │
|
||||
│ ────────────── │ │
|
||||
│ Paramètres │ │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- **Sidebar** : fixe, 230px. Fond blanc (`#ffffff`). Séparée par une bordure droite.
|
||||
- **Barre supérieure** : fixe, 48px. Fond blanc. Fil d'Ariane à gauche, utilisateur à droite.
|
||||
- **Contenu** : gris très pâle (`#fafafa` / `neutral-50`). Les cartes et tableaux ont un fond blanc, ce qui crée naturellement la séparation visuelle sans avoir besoin de bordures de section. Sur thème sombre le fond deviens noir est le boite sont grise.
|
||||
|
||||
---
|
||||
|
||||
## Navigation sidebar admin
|
||||
|
||||
### Structure
|
||||
|
||||
La sidebar fait **230px** de large, fixe. Fond blanc / noir complet en sombre. Séparée du contenu par une bordure droite `1px`.
|
||||
|
||||
Elle se compose de trois zones verticales :
|
||||
|
||||
1. **Logo** — hauteur 48px, aligné sur la barre supérieure. Séparé par une bordure basse.
|
||||
2. **Navigation principale** — défile si nécessaire. Padding `8px` autour des items.
|
||||
3. **Items épinglés en bas** — optionnels. Séparés par une bordure haute. Jamais dans la zone défilable.
|
||||
|
||||
### Items de navigation
|
||||
|
||||
Deux types d'items coexistent :
|
||||
|
||||
- **Lien direct** : section avec un seul item dont le nom est identique au titre de la section. S'affiche comme un lien simple, sans chevron.
|
||||
- **Section accordéon** : section avec plusieurs items. Un bouton parent déploie ou replie les sous-items.
|
||||
|
||||
### États visuels
|
||||
|
||||
| État | Apparence |
|
||||
|---|---|
|
||||
| Inactif | Texte `neutral-500` (sombre : `neutral-400`), fond transparent |
|
||||
| Hover | Fond `neutral-100` (sombre : `neutral-900`), texte `neutral-900` (sombre : blanc) |
|
||||
| Actif replié | Fond `neutral-100` (sombre : `neutral-900`), texte noir (sombre : blanc), graisse `500` |
|
||||
| Actif ouvert (parent) | Pas de fond, texte noir (sombre : blanc), graisse `500` — le fond actif est sur le sous-item |
|
||||
| Sous-item actif | Fond `neutral-100` (sombre : `neutral-900`), texte noir (sombre : blanc), graisse `500` |
|
||||
|
||||
### Gabarit d'un item
|
||||
|
||||
- Padding : `7px` vertical, `10px` horizontal.
|
||||
- Rayon : `8px`.
|
||||
- Icône à gauche, `15×15px`, `flex-shrink-0`. Gap icône–texte : `8px`.
|
||||
- Texte : `13px`.
|
||||
- Transition : `120ms ease-out`.
|
||||
- Badge numérique (optionnel) : à droite, fond rouge, texte blanc, `10px`, rayon `8px`.
|
||||
|
||||
### Sous-items
|
||||
|
||||
Pas d'icône. Indentation via `padding-left: 25px`. Même gabarit que les items parents.
|
||||
|
||||
### Accordéon
|
||||
|
||||
Le chevron (`12×12px`) est à l'extrême droite du bouton parent. Il pivote de `-90deg` quand la section est repliée, revient à `0deg` quand elle est ouverte. L'animation du conteneur : `max-height` + `opacity`, `120ms ease-out`.
|
||||
|
||||
Une section s'ouvre automatiquement au chargement si elle contient l'item actif courant. Toutes les autres sont repliées par défaut.
|
||||
|
||||
### Mobile
|
||||
|
||||
En dessous de `1024px`, la sidebar sort du flux et se positionne en fixe sur toute la hauteur. Elle est masquée par défaut (`translate-x: -100%`) et s'ouvre via un bouton externe. Un overlay semi-transparent (`black/50`) couvre le contenu derrière. Cliquer l'overlay ou naviguer vers un lien ferme la sidebar. Au redimensionnement au-delà de `1024px`, la sidebar mobile se ferme automatiquement.
|
||||
|
||||
---
|
||||
|
||||
## Boutons
|
||||
|
||||
Sept variantes :
|
||||
|
||||
| Variante | Apparence | Usage |
|
||||
|---|---|---|
|
||||
| **Primaire** | Fond `neutral-900`, texte blanc | Action principale de la page |
|
||||
| **Secondaire** | Fond transparent, bordure `neutral-300`, texte `neutral-700` | Actions secondaires, annulation |
|
||||
| **Fantôme** | Fond transparent, pas de bordure, texte `neutral-600` | Actions tertiaires |
|
||||
| **Fantôme plein** | Identique au fantôme, sans focus ring | Boutons très discrets dans des zones déjà interactives |
|
||||
| **Danger** | Fond rouge très pâle, bordure rouge pâle, texte rouge | Suppression, actions destructives |
|
||||
| **Succès** | Fond vert très pâle, bordure verte pâle, texte vert | Confirmation, validation |
|
||||
| **Avertissement** | Fond ambre très pâle, bordure ambre pâle, texte ambre | Alertes, actions risquées |
|
||||
|
||||
**Il ne doit jamais y avoir plus d'un bouton primaire par section ou en-tête de page.**
|
||||
|
||||
### Tailles
|
||||
|
||||
| Taille | Padding horizontal | Padding vertical | Texte |
|
||||
|---|---|---|---|
|
||||
| **sm** | 10px | 5px | 12px |
|
||||
| **md** | 12px | 7px | 13px |
|
||||
| **lg** | 14px | 9px | 14px |
|
||||
|
||||
La taille par défaut est `md`.
|
||||
|
||||
### Icônes et chargement
|
||||
|
||||
- Une icône peut se placer à gauche ou à droite du texte.
|
||||
- Un bouton icône seule (sans texte) a un padding uniforme.
|
||||
- L'état de chargement remplace le contenu par un spinner et désactive l'interaction.
|
||||
- L'état désactivé : opacité 50 %, curseur interdit.
|
||||
|
||||
### Règles communes
|
||||
|
||||
- Rayon : 8px (`rounded-lg`).
|
||||
- Transition : 120ms, `ease-out`.
|
||||
- Focus : anneau fin de la couleur de la variante.
|
||||
|
||||
---
|
||||
|
||||
## Cartes et panneaux
|
||||
|
||||
- Fond blanc.
|
||||
- Bordure 1px.
|
||||
- Rayon 12px (rounded-xl).
|
||||
- Padding interne 16–20px.
|
||||
- **Pas d'ombre.**
|
||||
- En-tête interne séparé du contenu par une bordure horizontale.
|
||||
- Pied de panneau (actions de formulaire) séparé par une bordure horizontale.
|
||||
|
||||
---
|
||||
|
||||
## Tableaux
|
||||
|
||||
- En-têtes de colonnes : texte 11px uppercase, couleur secondaire.
|
||||
- Lignes : fond blanc. Hover : fond légèrement grisé.
|
||||
- Bordure horizontale entre chaque ligne. Pas de bordure sur la dernière ligne.
|
||||
- Données numériques, IDs, dates : IBM Plex Mono.
|
||||
- Actions (modifier, supprimer) : à l'extrême droite, boutons icône discrets.
|
||||
- Pas de fond alterné sur les lignes.
|
||||
|
||||
---
|
||||
|
||||
## Badges de statut
|
||||
|
||||
Petits éléments inline qui indiquent un état. Toujours composés de trois éléments : fond pâle de la couleur sémantique, bordure légère, texte de la couleur sémantique.
|
||||
|
||||
Rayon : full. Taille du texte : 11px.
|
||||
|
||||
---
|
||||
|
||||
## États interactifs
|
||||
|
||||
| État | Apparence |
|
||||
|---|---|
|
||||
| Hover | Fond légèrement plus sombre (4–5% de gris) |
|
||||
| Actif / focus | Bordure de 1px devient la couleur du texte principal |
|
||||
| Désactivé | Opacité 50%, curseur interdit |
|
||||
| Chargement | Pas défini — à traiter au cas par cas |
|
||||
|
||||
Toutes les transitions durent **120ms**, courbe `ease-out`. Aucune animation décorative.
|
||||
|
||||
---
|
||||
|
||||
## Ce qu'on n'utilise jamais
|
||||
|
||||
- Dégradés de couleur sur les fonds ou les boutons
|
||||
- Cartes avec une bordure colorée uniquement sur le côté gauche
|
||||
- Ombres internes
|
||||
- Texte en gras uniquement pour l'emphase décorative
|
||||
- Icônes sans fonction claire
|
||||
- Statistiques ou chiffres inventés pour remplir l'espace
|
||||
- Couleurs de fond dans les sections de contenu
|
||||
|
||||
---
|
||||
|
||||
## Thème sombre
|
||||
|
||||
Le thème sombre est une inversion calibrée : les fonds passent au noir complet puis les zone par dessus sont noir-gris, les textes s'éclaircissent, les accents gagnent légèrement en luminosité pour rester lisibles. La structure, les espaces et les proportions restent identiques.
|
||||
+73
-42
@@ -4,19 +4,27 @@ Ce document couvre les conventions de code, les règles de sécurité et la proc
|
||||
|
||||
Pour les conventions de rédaction : [LANGUE.md](./dev/LANGUE.md) et [REDACTION.md](./dev/REDACTION.md).
|
||||
|
||||
Pour les décisions de design et l'identité visuelle : [DESIGN.md](./DESIGN.md).
|
||||
|
||||
Pour l'architecture partagée (composants, icônes) : [ARCHITECTURE.md](./dev/ARCHITECTURE.md).
|
||||
|
||||
Pour la procédure de publication du package : [PUBLICATION.md](./dev/PUBLICATION.md).
|
||||
|
||||
Pour les conventions de commit : [COMMITS.md](./dev/COMMITS.md).
|
||||
|
||||
> **Contexte projet** : les utilisateurs finaux créent leur projet via `npx @zen/start`, qui génère automatiquement une structure Next.js avec le CMS 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.
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## Standards de code
|
||||
|
||||
### Principes généraux
|
||||
**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 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é.
|
||||
|
||||
**Une fonction, une responsabilité.** Si elle fait deux choses, c'est deux fonctions. Si elle ne tient pas sur un écran, la découper.
|
||||
|
||||
@@ -24,67 +32,90 @@ Pour les conventions de commit : [COMMITS.md](./dev/COMMITS.md).
|
||||
|
||||
**Les données entrantes sont suspectes.** On valide en entrée de fonction. On ne suppose pas que l'appelant a fait le travail.
|
||||
|
||||
**Les promesses ne s'ignorent pas.** Chaque `Promise` est `await`ée ou `.catch()`ée. Une promesse silencieuse qui échoue est un bug invisible.
|
||||
|
||||
**La portée des variables est minimale.** On déclare au plus près de l'usage. Pas de variables réutilisées pour deux rôles différents.
|
||||
|
||||
**ESLint passe sans avertissement.** Un warning ignoré aujourd'hui est un bug non détecté demain.
|
||||
|
||||
**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é. Le code et sa documentation vieillissent ensemble.
|
||||
|
||||
**Les commentaires reflètent toujours le comportement réel du code.** Un commentaire obsolète est pire qu'un commentaire absent — il induit en erreur. Quand on modifie une fonction, on met à jour son commentaire. Un commentaire qui contredit le code est un bug de documentation.
|
||||
|
||||
---
|
||||
|
||||
## Conventions d'arborescence
|
||||
|
||||
Une feature (`src/features/<nom>/` ou `src/core/<nom>/`) suit la règle **flat + un barrel** :
|
||||
un `index.js` qui ré-exporte, des fichiers plats côte à côte pour l'implémentation. Pas de sous-dossier `lib/`, `middleware/`, `actions/` quand il ne contient qu'un ou deux fichiers — remonter directement au niveau du dossier feature.
|
||||
|
||||
Les sous-dossiers sont autorisés uniquement quand ils contiennent plusieurs fichiers du même rôle : `components/`, `pages/`, `templates/`, `widgets/`.
|
||||
|
||||
### Suffixes de runtime
|
||||
|
||||
Tout fichier épinglé à une frontière Next.js porte le suffixe dans son nom :
|
||||
|
||||
- `.server.js` → code serveur strict (peut importer `pg`, `fs`, etc.)
|
||||
- `.client.js` → débute par `'use client'`
|
||||
- pas de suffixe → module neutre, utilisable des deux côtés
|
||||
- `actions.js` → débute par `'use server'` (server actions Next.js)
|
||||
|
||||
Ces suffixes ne sont pas cosmétiques : **le build les utilise comme source de vérité**. 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
|
||||
|
||||
### Règle des externals
|
||||
`tsup.config.js` compile **tous les fichiers `.js` et `.jsx` de `src/`** avec `bundle: false`. Chaque fichier devient un module standalone dans `dist/` ; les imports relatifs sont préservés tels quels. La structure `dist/` reflète exactement `src/` grâce à `outbase: 'src'`.
|
||||
|
||||
Tout import de la forme `@zen/core/*` dans un fichier bundlé par tsup (typiquement `src/modules/*/api.js`, `src/modules/*/actions.js`, `src/modules/*/crud.js`) **doit figurer dans la liste `external`** du premier bloc de config dans `tsup.config.js`.
|
||||
- **Ajouter un module public = éditer seulement `package.json#exports`.** L'entry list se régénère au prochain build via un walk de `src/`.
|
||||
- **Pas de bundling des fichiers internes** : les modules de registre (`registry.js`, etc.) sont des singletons — les bundler créerait une copie inline distincte et casserait le partage d'état entre les pages et les widgets.
|
||||
- Les self-imports `@zen/core/*` sont générés automatiquement à partir des clés de `exports` et restent toujours dans la liste `external`.
|
||||
- Les fichiers `.js` et `.jsx` sont tous traités comme du JSX via esbuild (`loader: { '.js': 'jsx' }`, `jsx: 'automatic'`) — pas besoin d'extension `.jsx` pour écrire du JSX.
|
||||
|
||||
Pourquoi : tsup tente de résoudre ces imports au moment du build. Or les fichiers `dist/` n'existent pas encore — le build échoue avec `Could not resolve "@zen/core/..."`.
|
||||
---
|
||||
|
||||
**Règle :** quand on crée ou refactorise un module `src/core/*/index.js` exposé via `package.json` `exports`, on ajoute immédiatement l'entrée correspondante dans `external` de `tsup.config.js`.
|
||||
## Étendre l'admin
|
||||
|
||||
L'admin utilise un **registre runtime** pour permettre aux projets consommateurs d'ajouter des widgets, des entrées de navigation, et des pages sans modifier le core.
|
||||
|
||||
> 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
|
||||
// tsup.config.js — external (premier bloc)
|
||||
'@zen/core/api', // ← à ajouter si src/core/api/index.js est un entry tsup
|
||||
'@zen/core/database',
|
||||
'@zen/core/storage',
|
||||
// etc.
|
||||
// app/zen.extensions.js — projet consommateur
|
||||
import {
|
||||
registerWidget,
|
||||
registerWidgetFetcher,
|
||||
registerNavItem,
|
||||
registerNavSection,
|
||||
registerPage,
|
||||
} from '@zen/core/features/admin';
|
||||
import OrdersWidget from './admin/OrdersWidget';
|
||||
import OrdersPage from './admin/OrdersPage';
|
||||
import { countOrders } from './admin/orders.server';
|
||||
|
||||
registerWidgetFetcher('orders', async () => ({ total: await countOrders() }));
|
||||
registerWidget({ id: 'orders', Component: OrdersWidget, order: 20 });
|
||||
|
||||
registerNavSection({ id: 'commerce', title: 'Commerce', icon: 'ShoppingBag03Icon', order: 30 });
|
||||
registerNavItem({ id: 'orders', label: 'Commandes', icon: 'ShoppingBag03Icon', href: '/admin/orders', sectionId: 'commerce', permission: 'orders.view' });
|
||||
|
||||
registerPage({ slug: 'orders', Component: OrdersPage, title: 'Commandes' });
|
||||
```
|
||||
|
||||
L'item actif dans la sidebar et le fil d'Ariane sont calculés automatiquement à partir d'un `basePath` déduit de `href` (si `href` finit par `/list`, on retire le suffixe). Une convention `slug:edit` / `slug:new` permet de personnaliser les labels du breadcrumb sur les sous-routes — voir [src/features/admin/README.md](../src/features/admin/README.md#item-actif-et-fil-dariane) pour le détail.
|
||||
|
||||
```js
|
||||
// app/layout.js — un seul import suffit ; les side effects enregistrent tout.
|
||||
import './zen.extensions';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Sécurité
|
||||
|
||||
### Données entrantes
|
||||
**Données entrantes** : toute donnée externe est considérée malveillante par défaut. On valide côté serveur uniquement.
|
||||
|
||||
Toute donnée externe est considérée malveillante par défaut : requêtes HTTP, données formulaire, réponses d'API tierce, contenu de fichier. On valide côté serveur. Le client ne contrôle rien de critique.
|
||||
**Base de données** : uniquement des requêtes paramétrées — jamais de SQL construit par concaténation de chaînes.
|
||||
|
||||
### Base de données
|
||||
**Secrets** : aucun token, clé API ou mot de passe dans le code. Tout passe par des variables d'environnement, jamais commitées.
|
||||
|
||||
On n'écrit jamais de SQL par concaténation de chaînes. Uniquement des requêtes paramétrées :
|
||||
|
||||
```ts
|
||||
// ✓
|
||||
await pool.query('SELECT * FROM users WHERE id = $1', [userId])
|
||||
|
||||
// ✗
|
||||
await pool.query(`SELECT * FROM users WHERE id = '${userId}'`)
|
||||
```
|
||||
|
||||
### Secrets
|
||||
|
||||
Aucun token, clé API ou mot de passe dans le code. Tout passe par des variables d'environnement. Les clés Stripe, les tokens Resend et les credentials PostgreSQL ne sont jamais commités.
|
||||
|
||||
```bash
|
||||
# .env.local — jamais dans git
|
||||
DATABASE_URL=...
|
||||
STRIPE_SECRET_KEY=...
|
||||
RESEND_API_KEY=...
|
||||
```
|
||||
|
||||
### Erreurs exposées
|
||||
|
||||
Les messages d'erreur retournés à l'utilisateur ne contiennent pas de détails internes : pas de stack trace, pas de nom de table, pas de requête SQL. On log côté serveur, on renvoie un message générique côté client.
|
||||
**Erreurs exposées** : pas de stack trace, nom de table ou requête SQL retournés au client. On log côté serveur, on renvoie un message générique côté client.
|
||||
|
||||
+501
@@ -0,0 +1,501 @@
|
||||
# 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.). |
|
||||
|
||||
### Médias attachés au contenu
|
||||
|
||||
Pour qu'un module puisse attacher des médias (image à la une, galerie, PDF) à ses propres ressources, utiliser `@zen/core/features/media` côté serveur et `@zen/core/features/media/picker` côté client.
|
||||
|
||||
```js
|
||||
// côté serveur — au moment de sauvegarder un billet :
|
||||
import { attachMedia, detachAllForSource } from '@zen/core/features/media';
|
||||
|
||||
await attachMedia({
|
||||
mediaId: payload.featuredImageId,
|
||||
sourceType: '@zen/module-blog:post',
|
||||
sourceId: post.id,
|
||||
field: 'featured_image',
|
||||
});
|
||||
|
||||
// au moment de supprimer le billet : libérer toutes les références.
|
||||
await detachAllForSource({ sourceType: '@zen/module-blog:post', sourceId: post.id });
|
||||
```
|
||||
|
||||
```jsx
|
||||
// côté client — sélecteur dans un formulaire :
|
||||
'use client';
|
||||
import MediaPicker from '@zen/core/features/media/picker';
|
||||
|
||||
<MediaPicker
|
||||
isOpen={open}
|
||||
onClose={() => setOpen(false)}
|
||||
accept="image/*"
|
||||
visibility="public"
|
||||
onSelect={(media) => setFeaturedImage(media)}
|
||||
/>
|
||||
```
|
||||
|
||||
Le module Médias doit être activé (`ZEN_MEDIA=true`) dans le projet consommateur — sinon les permissions ne sont pas seedées et les API renvoient 403. Vérifier avec `isMediaEnabled()` côté serveur si on veut adapter l'UI.
|
||||
|
||||
### 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/features/media/picker` | Composant client `MediaPicker` (modale de sélection de média). Importer **uniquement ce sous-chemin** depuis un client — `@zen/core/features/media` (barrel) tire le code serveur. |
|
||||
| `@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.
|
||||
|
||||
---
|
||||
|
||||
## Classes Tailwind
|
||||
|
||||
Le projet consommateur importe `@zen/core/styles/zen.css`, qui déclare deux directives `@source` :
|
||||
|
||||
1. `@source "../../**/*.js"` — scanne `node_modules/@zen/core/dist/**/*.js`
|
||||
2. `@source "../../../../module-*/dist/**/*.js"` — scanne `node_modules/@zen/module-*/dist/**/*.js`
|
||||
|
||||
Conséquence pour un module : les classes Tailwind utilisées dans les composants du module sont générées **automatiquement** par le Tailwind du consommateur, à condition que :
|
||||
|
||||
- Le package soit publié sous le nom `@zen/module-<nom>` (la convention est déjà imposée par la découverte runtime des modules).
|
||||
- Les fichiers compilés se trouvent dans `dist/` (pattern de scan).
|
||||
|
||||
Aucune action n'est requise côté module ni côté consommateur — un module peut donc utiliser librement n'importe quelle classe Tailwind, y compris des variantes responsives (`md:`, `lg:`...) ou des valeurs arbitraires (`w-[300px]`).
|
||||
|
||||
---
|
||||
|
||||
## 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 |
|
||||
@@ -0,0 +1,71 @@
|
||||
# ZEN — Plan du projet
|
||||
|
||||
> Une plateforme multi-usage construite sur l'essentiel, rien de plus, rien de moins.
|
||||
|
||||
ZEN est une plateforme admin Next.js qui sert de base à tout type d'application : site vitrine, système de facturation, espace cloud, boutique en ligne, ou n'importe quoi d'autre. L'admin est extensible, on y greffe ce qu'on veut, sans modifier le core.
|
||||
|
||||
Elle s'installe automatiquement dans n'importe quel projet Next.js via :
|
||||
|
||||
```bash
|
||||
npx @zen/start
|
||||
```
|
||||
|
||||
Le package principal est `@zen/core`. Il fournit toute l'infrastructure fondamentale : authentification, base de données, stockage, courriels, paiements, PDF, tâches planifiées, notifications. Chaque fonctionnalité est indépendante — les cores ne se connaissent pas entre eux. Le reste de la plateforme s'appuie sur chacun d'eux sans les coupler.
|
||||
|
||||
---
|
||||
|
||||
## Principes directeurs
|
||||
|
||||
**Core purity** — Chaque core contient uniquement du code propre à son domaine. Aucune logique métier, aucune dépendance vers un autre core.
|
||||
|
||||
**Minimal by default** — Pas de fonctionnalité superflue. Si ce n'est pas nécessaire, ça n'existe pas.
|
||||
|
||||
**Sécuritaire par défaut** — Requêtes paramétrées, protection CSRF, limitation de débit, validation en entrée, erreurs opaques vers le client.
|
||||
|
||||
**Performant** — Connexions en pool, cache HTTP, génération différée des services.
|
||||
|
||||
---
|
||||
|
||||
## Structure du projet
|
||||
|
||||
```
|
||||
src/
|
||||
core/ # Infrastructure fondamentale — la base de tout
|
||||
features/ # Fonctionnalités de la palteforme utilisant les cores
|
||||
shared/ # Utilitaires, composants et styles partagés
|
||||
```
|
||||
---
|
||||
|
||||
## Résumé des règles absolues
|
||||
|
||||
| Domaine | Règle |
|
||||
|---|---|
|
||||
| API | Toutes les routes HTTP passent par `core/api`. Aucune autre route API. |
|
||||
| Base de données | Tout accès DB passe par `core/database`. Jamais de requêtes directes. |
|
||||
| Courriels | Tout envoi de courriel passe par `core/email`. |
|
||||
| Cron | Toutes les tâches planifiées s'enregistrent dans `core/cron`. |
|
||||
| Stockage | Tout accès fichier passe par `core/storage`. |
|
||||
| Notifications | Un seul système de toast dans toute l'app : `core/toast`. |
|
||||
| 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.
|
||||
@@ -32,3 +32,7 @@ Ces modules existent pour éviter la duplication. Avant d'écrire du code utilit
|
||||
**Tâches planifiées** — Utiliser `src/core/cron` pour créer des tâches cron.
|
||||
|
||||
**API** — Utiliser `src/core/api` pour l'API admin et publique. Définir les routes avec `defineApiRoutes()` (valide la config au démarrage). L'authentification est déclarée dans la définition de route (`auth: 'public' | 'user' | 'admin'`) — ne jamais la vérifier manuellement dans un handler. Retourner `apiSuccess()` / `apiError()` dans tous les handlers. Voir `src/core/api/README.md` pour le détail.
|
||||
|
||||
**Médias** — Pour gérer les fichiers attachés au contenu publié (images, PDFs, vidéos), utiliser `src/features/media`. Activable via `ZEN_MEDIA=true`. Expose `uploadMedia`/`deleteMedia`/`attachMedia`/`detachMedia` côté serveur et un composant `MediaPicker` réutilisable côté client. Voir `src/features/media/README.md`. Distinct d'un futur module `files` (style Drive/Dropbox) — ne pas confondre les deux usages.
|
||||
|
||||
Pour **afficher** une image média, utiliser le wrapper `MediaImage` du module (pas `<img>` direct, pas `next/image` direct). Il route les médias publics via `next/image` (srcset AVIF/WebP, lazy) et garde un `<img>` natif pour les médias privés — `/_next/image` cache la réponse optimisée par URL et fuiterait l'image privée à tous les visiteurs après le premier hit.
|
||||
+103
-127
@@ -1,163 +1,139 @@
|
||||
# RÉDACTION
|
||||
Dernière modification : 2026-04-12
|
||||
# Rédaction
|
||||
|
||||
## La voix de l'entreprise
|
||||
|
||||
On parle comme une personne, pas comme une organisation. Direct, sans fioritures.
|
||||
|
||||
On dit **"on"** — jamais "nous sommes heureux de", jamais "notre équipe d'experts". Une seule exception : quand on cite quelqu'un nommément, on peut utiliser "je".
|
||||
|
||||
La technique est là parce qu'elle est utile, pas pour impressionner. Si une explication technique ne sert pas le lecteur, elle ne sert pas le texte.
|
||||
Ce guide s'applique à tout contenu textuel visible par l'utilisateur dans ce projet : l'interface admin, les messages d'état, les documentations dans `docs/` et les README.
|
||||
|
||||
---
|
||||
|
||||
## Ce qu'on évite absolument
|
||||
## Deux contextes, deux voix
|
||||
|
||||
**Les superlatifs vides**
|
||||
*de premier plan, leader, de pointe, best-in-class, incontournable*
|
||||
**Interface (labels, messages, descriptions)** : la plateforme rapporte ce qui s'est passé. On utilise l'impersonnel et le passif descriptif. L'utilisateur est le sujet implicite.
|
||||
|
||||
**Les promesses floues**
|
||||
*solutions sur mesure, accompagnement personnalisé, approche holistique*
|
||||
**Documentation** : on s'adresse directement au développeur qui lit. Phrases courtes, verbes actifs, ton direct.
|
||||
|
||||
---
|
||||
|
||||
## Ce qu'on évite partout
|
||||
|
||||
**Superlatifs et formules vides**
|
||||
*de premier plan, robuste, puissant, tout-en-un, intuitif, seamless*
|
||||
|
||||
**Le corporate**
|
||||
*nous nous engageons à, notre mission est de, dans une optique de, à cet effet*
|
||||
*notre plateforme vous permet de, dans une optique de, à cet effet*
|
||||
|
||||
**La sur-promesse**
|
||||
Si on ne peut pas le garantir, on ne l'écrit pas. Jamais.
|
||||
|
||||
**Les métaphores usées**
|
||||
*pont entre, clé en main, écosystème, synergies, à 360°*
|
||||
**Chevilles inutiles**
|
||||
*Ainsi, En effet, Il convient de noter que, N'hésitez pas à, Veuillez noter que*
|
||||
|
||||
**Le tiret long (—)**
|
||||
Reformuler la phrase plutôt que d'insérer une incise.
|
||||
Reformuler la phrase à la place.
|
||||
|
||||
**Les chevilles inutiles**
|
||||
*Ainsi, En effet, Il convient de noter que, N'hésitez pas à*
|
||||
|
||||
**Le passif sans raison**
|
||||
Préférer la forme active. "On a livré le projet" plutôt que "le projet a été livré".
|
||||
**La sur-promesse**
|
||||
Si on ne peut pas le garantir, on ne l'écrit pas.
|
||||
|
||||
---
|
||||
|
||||
## Formules qui marchent
|
||||
## Interface
|
||||
|
||||
**Plutôt ça :**
|
||||
> "On dit non quand c'est la bonne réponse."
|
||||
> "On pense à comment les choses vont tenir dans six mois."
|
||||
> "Ce n'est pas le bon projet pour nous. Voilà pourquoi."
|
||||
### Voix de l'interface
|
||||
|
||||
**Pas ça :**
|
||||
> "Notre approche centrée client garantit des résultats optimaux."
|
||||
> "Nous mettons notre expertise au service de vos ambitions."
|
||||
> "Une équipe passionnée à votre écoute."
|
||||
La plateforme rapporte. Elle ne dit pas "on a fait" — elle dit ce qui s'est passé.
|
||||
|
||||
**La règle d'or :** si ça pourrait figurer dans la communication d'un concurrent sans changer un mot, c'est à réécrire.
|
||||
> ✓ "Les modifications ont été enregistrées."
|
||||
> ✓ "La page a été supprimée."
|
||||
> ✗ "On a enregistré vos modifications."
|
||||
> ✗ "Nous avons bien reçu votre demande."
|
||||
|
||||
Pas de "vous" ni de "nous". L'état parle pour lui-même.
|
||||
|
||||
### Labels et boutons
|
||||
|
||||
Courts, un verbe d'action à l'infinitif, sans point.
|
||||
|
||||
> ✓ "Enregistrer", "Supprimer", "Ajouter une page"
|
||||
> ✗ "Cliquez ici pour enregistrer vos modifications"
|
||||
|
||||
Pas de majuscule à chaque mot.
|
||||
|
||||
> ✓ "Ajouter un élément"
|
||||
> ✗ "Ajouter Un Élément"
|
||||
|
||||
### Messages d'erreur
|
||||
|
||||
Nommer ce qui ne va pas. Dire quoi faire si c'est utile.
|
||||
|
||||
> ✓ "Ce champ est requis."
|
||||
> ✓ "Ce slug est déjà utilisé. Choisir un autre."
|
||||
> ✗ "Une erreur s'est produite. Veuillez réessayer."
|
||||
|
||||
Pas de code d'erreur technique si l'utilisateur n'est pas développeur.
|
||||
|
||||
### Messages de confirmation
|
||||
|
||||
Une phrase. Ce qui s'est passé, au passé passif. Sans "avec succès".
|
||||
|
||||
> ✓ "Page enregistrée."
|
||||
> ✓ "Modifications enregistrées."
|
||||
> ✓ "Élément supprimé."
|
||||
> ✗ "Vos modifications ont été enregistrées avec succès !"
|
||||
|
||||
### États vides
|
||||
|
||||
Expliquer la situation, pas juste la constater. Ajouter l'action à faire si applicable.
|
||||
|
||||
> ✓ "Aucune page pour l'instant. Créer la première."
|
||||
> ✗ "Aucun résultat trouvé."
|
||||
|
||||
### Textes d'aide et descriptions
|
||||
|
||||
Une phrase. Répondre à "pourquoi" ou "comment", pas aux deux.
|
||||
|
||||
> ✓ "Utilisé dans l'URL. Ne peut pas être modifié après publication."
|
||||
> ✗ "Le slug est un identifiant unique utilisé pour générer l'URL de la page. Il doit être en minuscules et ne peut pas contenir d'espaces ou de caractères spéciaux."
|
||||
|
||||
---
|
||||
|
||||
## Structure des textes
|
||||
## Documentation
|
||||
|
||||
### Voix de la documentation
|
||||
|
||||
On s'adresse directement au développeur. Verbes actifs, phrases courtes, sans intermédiaire.
|
||||
|
||||
> ✓ "Le registre permet d'ajouter des widgets sans toucher au core. Importer `registerWidget` et passer un composant."
|
||||
> ✗ "Le registre est un système centralisé qui permet la gestion modulaire des composants d'interface."
|
||||
|
||||
### Structure
|
||||
|
||||
- **Titre :** ce que le document permet de faire.
|
||||
- **Première phrase :** pourquoi ce document existe, à qui il s'adresse.
|
||||
- **Prérequis si nécessaire :** ce qu'il faut savoir ou avoir avant.
|
||||
- **Corps :** une idée par section, titres descriptifs.
|
||||
- **Notes :** à la fin, pas dans le milieu.
|
||||
|
||||
### Titres
|
||||
Courts, affirmatifs, sans point. Une idée, pas une liste. Pas de question rhétorique.
|
||||
|
||||
> ✓ "Zéro raccourci. Zéro compromis."
|
||||
> ✓ "On livre. On reste."
|
||||
> ✗ "Une approche rigoureuse pour des résultats durables"
|
||||
> ✗ "Pourquoi nous choisir ?"
|
||||
Descriptifs, pas génériques.
|
||||
|
||||
> ✓ "Ajouter un widget à l'admin"
|
||||
> ✗ "Configuration"
|
||||
|
||||
### Corps de texte
|
||||
Phrases courtes. Une idée par phrase. Maximum deux virgules par phrase — si on en compte plus, couper.
|
||||
|
||||
Paragraphes de trois à quatre phrases maximum. Une ligne blanche entre chaque paragraphe.
|
||||
Phrases courtes. Une idée par phrase. Paragraphes de trois à quatre phrases maximum.
|
||||
|
||||
Pas de bullet points pour des concepts. Seulement pour des listes réelles (étapes, éléments techniques, options).
|
||||
Commencer par un constat ou une situation concrète, pas par une définition.
|
||||
|
||||
### Appels à l'action
|
||||
Concrets et à l'infinitif. Pas d'exclamation.
|
||||
|
||||
> ✓ "Discuter de votre projet"
|
||||
> ✓ "Voir comment on travaille"
|
||||
> ✗ "Contactez-nous pour en savoir plus !"
|
||||
> ✗ "Passez à l'action"
|
||||
Pas de listes à puces pour des explications. Les listes servent pour les étapes, les options, les éléments techniques.
|
||||
|
||||
---
|
||||
|
||||
## Selon le format
|
||||
## Révision avant de livrer
|
||||
|
||||
### Site web
|
||||
Chaque page a une seule idée principale. Le visiteur doit comprendre l'essentiel en dix secondes. Les détails viennent après, pour ceux qui cherchent.
|
||||
|
||||
Pas de jargon en page d'accueil. Le jargon technique appartient aux pages de service, là où le lecteur s'y attend.
|
||||
|
||||
### Courriels
|
||||
Objet : concret, pas accrocheur. Dire ce dont il s'agit, pas promettre quelque chose.
|
||||
|
||||
> ✓ "Proposition — Refonte infrastructure"
|
||||
> ✗ "Une opportunité à ne pas manquer"
|
||||
|
||||
Corps du courriel : aller droit au but dès la première phrase. Pas de "j'espère que ce message vous trouve bien." Si on doit écrire plus de quatre paragraphes, se demander si c'est le bon format.
|
||||
|
||||
### Applications et interfaces
|
||||
Chaque message, étiquette ou instruction doit avoir un seul sens possible. Pas de formulation qui force l'utilisateur à interpréter.
|
||||
|
||||
Le ton reste humain, même dans un contexte technique. "Une erreur s'est produite" est mieux que "Erreur 403 — accès non autorisé" si le lecteur n'est pas développeur.
|
||||
|
||||
Les messages de confirmation, d'erreur et d'aide suivent les mêmes règles que le reste : courts, directs, actifs.
|
||||
|
||||
### Articles et billets
|
||||
Un angle précis, pas un survol. Mieux vaut un article sur un problème concis que dix paragraphes vagues.
|
||||
|
||||
Commencer par un fait, un constat ou une situation — pas par une définition.
|
||||
|
||||
> ✓ "La plupart des migrations ratent pour la même raison : on sous-estime ce qui dépend de ce qu'on déplace."
|
||||
> ✗ "La migration de données est un processus complexe qui nécessite une planification rigoureuse."
|
||||
|
||||
Terminer sur ce qu'on retient, pas sur une invitation à nous contacter.
|
||||
|
||||
### Propositions et soumissions
|
||||
Pas de section "qui sommes-nous" en ouverture. Le client le sait déjà ou s'en fout pour l'instant.
|
||||
|
||||
Commencer par le problème du client, tel qu'on l'a compris. S'il se reconnaît dans les deux premiers paragraphes, le reste a de la valeur.
|
||||
|
||||
La solution vient après le diagnostic. Jamais avant.
|
||||
|
||||
### Documents internes
|
||||
Mêmes règles de clarté que pour les textes externes. Un document interne mal écrit crée autant de confusion qu'une mauvaise communication client.
|
||||
|
||||
Titres de section descriptifs, pas génériques. "Décision retenue et raison" est mieux que "Résultats".
|
||||
|
||||
Si un document dépasse deux pages, se demander si l'information ne serait pas mieux transmise autrement.
|
||||
|
||||
### Réseaux sociaux
|
||||
Un seul message par publication. Si on a besoin d'un fil de publications pour expliquer, c'est probablement un article.
|
||||
|
||||
Le ton reste celui de l'entreprise — pas plus décontracté sous prétexte que c'est un réseau social.
|
||||
|
||||
Pas de hashtags décoratifs. Seulement ceux qui servent la découverte du contenu.
|
||||
- [ ] La première phrase dit déjà l'essentiel.
|
||||
- [ ] Chaque phrase dit quelque chose. Les phrases qui meublent sont supprimées.
|
||||
- [ ] L'interface utilise l'impersonnel. La doc s'adresse directement au lecteur.
|
||||
- [ ] Il n'y a pas de tiret long (—).
|
||||
- [ ] Les fautes sont corrigées.
|
||||
- [ ] L'utilisateur sait quoi faire ou quoi retenir après avoir lu.
|
||||
|
||||
---
|
||||
|
||||
## Parler de ce qu'on fait
|
||||
|
||||
Ne jamais affirmer qu'on est "les meilleurs" ou "différents des autres". Le montrer, pas le dire.
|
||||
|
||||
Quand on parle de ce qu'on fait, on parle de situations concrètes. Pas de généralités.
|
||||
|
||||
> ✓ "On a refusé un mandat parce que le calendrier ne tenait pas."
|
||||
> ✗ "On valorise l'honnêteté dans nos relations clients."
|
||||
|
||||
---
|
||||
|
||||
## Révision d'un texte
|
||||
|
||||
Avant de publier ou d'envoyer, se poser ces questions :
|
||||
|
||||
1. **Est-ce qu'un concurrent pourrait signer ce texte ?** Si oui, réécrire.
|
||||
2. **Est-ce qu'on promet quelque chose qu'on ne contrôle pas ?** Retirer ou nuancer.
|
||||
3. **Est-ce que chaque phrase dit quelque chose ?** Supprimer celles qui ne font que meubler.
|
||||
4. **Est-ce que le lecteur sait quoi faire ou quoi penser après ?** Si non, préciser.
|
||||
5. **A-t-on utilisé le mot "solution" ?** Le remplacer. Presque toujours.
|
||||
|
||||
---
|
||||
|
||||
*Ce guide évolue. Si une formule sonne faux ou si un cas n'est pas couvert, on l'ajuste.*
|
||||
*Ce guide évolue. Si une formule sonne faux ou si un cas n'est pas couvert, l'ajuster.*
|
||||
|
||||
Generated
+37
-48
@@ -1,25 +1,26 @@
|
||||
{
|
||||
"name": "@zen/core",
|
||||
"version": "1.3.17",
|
||||
"version": "1.4.210",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@zen/core",
|
||||
"version": "1.3.17",
|
||||
"version": "1.4.210",
|
||||
"license": "GPL-3.0-only",
|
||||
"dependencies": {
|
||||
"@headlessui/react": "^2.0.0",
|
||||
"@react-email/components": "^0.5.6",
|
||||
"@react-pdf/renderer": "^4.3.1",
|
||||
"dotenv": "^16.4.5",
|
||||
"node-cron": "^3.0.3",
|
||||
"node-cron": "^4.2.1",
|
||||
"pg": "^8.11.3",
|
||||
"resend": "^3.2.0",
|
||||
"stripe": "^14.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"zen-db": "dist/core/database/cli.js"
|
||||
"zen-db": "dist/core/database/cli.js",
|
||||
"zen-modules": "dist/core/modules/cli.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"react": "^19.0.0",
|
||||
@@ -3653,43 +3654,11 @@
|
||||
"tslib": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/next/node_modules/postcss": {
|
||||
"version": "8.4.31",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
|
||||
"integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/postcss/"
|
||||
},
|
||||
{
|
||||
"type": "tidelift",
|
||||
"url": "https://tidelift.com/funding/github/npm/postcss"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.6",
|
||||
"picocolors": "^1.0.0",
|
||||
"source-map-js": "^1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/node-cron": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/node-cron/-/node-cron-3.0.3.tgz",
|
||||
"integrity": "sha512-dOal67//nohNgYWb+nWmg5dkFdIwDm8EpeGYMekPMrngV3637lqnX0lbUcCtgibHTz6SEz7DAIjKvKDFYCnO1A==",
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.2.1.tgz",
|
||||
"integrity": "sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"uuid": "8.3.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
@@ -3913,6 +3882,35 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.10",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz",
|
||||
"integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/postcss/"
|
||||
},
|
||||
{
|
||||
"type": "tidelift",
|
||||
"url": "https://tidelift.com/funding/github/npm/postcss"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss-load-config": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz",
|
||||
@@ -5355,15 +5353,6 @@
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "8.3.2",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
|
||||
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"uuid": "dist/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/vite-compatible-readable-stream": {
|
||||
"version": "3.6.1",
|
||||
"resolved": "https://registry.npmjs.org/vite-compatible-readable-stream/-/vite-compatible-readable-stream-3.6.1.tgz",
|
||||
|
||||
+60
-25
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@zen/core",
|
||||
"version": "1.3.17",
|
||||
"description": "Un CMS Next.js construit sur l'essentiel, rien de plus, rien de moins.",
|
||||
"version": "1.4.210",
|
||||
"description": "Une plateforme multi-usage construite sur l'essentiel, rien de plus, rien de moins.",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://git.hyko.cx/zen/core.git"
|
||||
@@ -20,18 +20,20 @@
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsup && npm run build:css",
|
||||
"build:css": "mkdir -p ./dist/shared/styles && cp ./src/shared/styles/zen.css ./dist/shared/styles/zen.css",
|
||||
"prepublishOnly": "npm run build"
|
||||
"build:css": "mkdir -p ./dist/shared/styles ./dist/shared/fonts && cp ./src/shared/styles/zen.css ./dist/shared/styles/zen.css && cp -r ./src/shared/fonts/. ./dist/shared/fonts/",
|
||||
"prepublishOnly": "npm run build",
|
||||
"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": {
|
||||
"zen-db": "./dist/core/database/cli.js"
|
||||
"zen-db": "./dist/core/database/cli.js",
|
||||
"zen-modules": "./dist/core/modules/cli.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@headlessui/react": "^2.0.0",
|
||||
"@react-email/components": "^0.5.6",
|
||||
"@react-pdf/renderer": "^4.3.1",
|
||||
"dotenv": "^16.4.5",
|
||||
"node-cron": "^3.0.3",
|
||||
"node-cron": "^4.2.1",
|
||||
"pg": "^8.11.3",
|
||||
"resend": "^3.2.0",
|
||||
"stripe": "^14.0.0"
|
||||
@@ -46,6 +48,9 @@
|
||||
"next": ">=14.0.0",
|
||||
"react": "^19.0.0"
|
||||
},
|
||||
"overrides": {
|
||||
"postcss": "^8.5.10"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.js"
|
||||
@@ -56,37 +61,64 @@
|
||||
"./features/auth/actions": {
|
||||
"import": "./dist/features/auth/actions.js"
|
||||
},
|
||||
"./features/auth/server": {
|
||||
"import": "./dist/features/auth/AuthPage.server.js"
|
||||
},
|
||||
"./features/auth/client": {
|
||||
"import": "./dist/features/auth/AuthPage.client.js"
|
||||
},
|
||||
"./features/auth/pages": {
|
||||
"import": "./dist/features/auth/pages.js"
|
||||
},
|
||||
"./features/auth/page": {
|
||||
"import": "./dist/features/auth/page.js"
|
||||
},
|
||||
"./features/auth/components": {
|
||||
"import": "./dist/features/auth/components/index.js"
|
||||
"import": "./dist/features/auth/pages/index.js"
|
||||
},
|
||||
"./features/admin": {
|
||||
"import": "./dist/features/admin/index.js"
|
||||
},
|
||||
"./features/admin/actions": {
|
||||
"import": "./dist/features/admin/actions.js"
|
||||
"./features/admin/protect": {
|
||||
"import": "./dist/features/admin/protect.js"
|
||||
},
|
||||
"./features/admin/navigation": {
|
||||
"import": "./dist/features/admin/navigation.server.js"
|
||||
"./features/admin/server": {
|
||||
"import": "./dist/features/admin/AdminPage.server.js"
|
||||
},
|
||||
"./features/admin/pages": {
|
||||
"import": "./dist/features/admin/pages.js"
|
||||
"./features/admin/layout": {
|
||||
"import": "./dist/features/admin/AdminLayout.server.js"
|
||||
},
|
||||
"./features/admin/page": {
|
||||
"import": "./dist/features/admin/page.js"
|
||||
"./features/admin/client": {
|
||||
"import": "./dist/features/admin/AdminPage.client.js"
|
||||
},
|
||||
"./features/admin/components": {
|
||||
"import": "./dist/features/admin/components/index.js"
|
||||
},
|
||||
"./features/media": {
|
||||
"import": "./dist/features/media/index.js"
|
||||
},
|
||||
"./features/media/picker": {
|
||||
"import": "./dist/features/media/components/MediaPicker.client.js"
|
||||
},
|
||||
"./features/media/details-modal": {
|
||||
"import": "./dist/features/media/components/MediaDetailsModal.client.js"
|
||||
},
|
||||
"./features/provider": {
|
||||
"import": "./dist/features/provider/index.js"
|
||||
},
|
||||
"./users": {
|
||||
"import": "./dist/core/users/index.js"
|
||||
},
|
||||
"./users/constants": {
|
||||
"import": "./dist/core/users/constants.js"
|
||||
},
|
||||
"./modules": {
|
||||
"import": "./dist/core/modules/index.js"
|
||||
},
|
||||
"./public-pages": {
|
||||
"import": "./dist/core/public-pages/index.js"
|
||||
},
|
||||
"./public-pages/server": {
|
||||
"import": "./dist/core/public-pages/PublicModulePage.server.js"
|
||||
},
|
||||
"./api": {
|
||||
"import": "./dist/core/api/index.js"
|
||||
},
|
||||
"./zen/api": {
|
||||
"./api/handler": {
|
||||
"import": "./dist/core/api/route-handler.js"
|
||||
},
|
||||
"./database": {
|
||||
@@ -122,10 +154,13 @@
|
||||
"./shared/components": {
|
||||
"import": "./dist/shared/components/index.js"
|
||||
},
|
||||
"./shared/icons": {
|
||||
"import": "./dist/shared/Icons.js"
|
||||
"./shared/components/BlockEditor/mediaLink": {
|
||||
"import": "./dist/shared/components/BlockEditor/mediaLink.server.js"
|
||||
},
|
||||
"./shared/lib/metadata": {
|
||||
"./shared/icons": {
|
||||
"import": "./dist/shared/icons/index.js"
|
||||
},
|
||||
"./shared/metadata": {
|
||||
"import": "./dist/shared/lib/metadata/index.js"
|
||||
},
|
||||
"./shared/logger": {
|
||||
|
||||
@@ -37,6 +37,7 @@ route-handler.js (GET / POST / PUT / DELETE / PATCH)
|
||||
├─ matchRoute(pattern, path) — exact, :param, /**
|
||||
├─ Auth enforcement (depuis la définition de la route)
|
||||
│ 'admin' → requireAdmin() — session dans context.session
|
||||
│ │ si `permission` est défini → hasPermission() → 403 si refusé
|
||||
│ 'user' → requireAuth() — session dans context.session
|
||||
│ 'public'→ aucun — context.session = undefined
|
||||
└─ handler(request, params, context)
|
||||
@@ -175,6 +176,13 @@ Champs requis par route :
|
||||
| `handler` | `Function` | Signature : `(request, params, context) => Promise<Object>` |
|
||||
| `auth` | `string` | `'public'` \| `'user'` \| `'admin'` |
|
||||
|
||||
Champs optionnels :
|
||||
|
||||
| Champ | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `skipRateLimit` | `boolean` | Exempte la route du rate limiting par IP (ex. health checks) |
|
||||
| `permission` | `string` | Sur une route `auth: 'admin'`, exige en plus cette clé de permission granulaire (ex. `'users.manage'`). Retourne 403 si l'utilisateur ne la possède pas. Voir `PERMISSIONS` dans `src/core/users/constants.js` |
|
||||
|
||||
---
|
||||
|
||||
## Note — handler storage
|
||||
|
||||
@@ -23,6 +23,10 @@
|
||||
* check for this route. Use sparingly — only for routes
|
||||
* that must remain accessible under high probe frequency
|
||||
* (e.g. health checks from monitoring systems).
|
||||
* permission {string} When set on an 'admin' route, the router additionally
|
||||
* verifies that the authenticated user holds this granular
|
||||
* permission key (e.g. 'users.manage'). If the user lacks
|
||||
* the permission, the request is rejected with 403 Forbidden.
|
||||
*
|
||||
* Auth levels:
|
||||
* 'public' Anyone can call this route. context.session is undefined.
|
||||
@@ -77,6 +81,11 @@ export function defineApiRoutes(routes) {
|
||||
`${at} (${route.method} ${route.path}) — "skipRateLimit" must be a boolean when provided, got: ${JSON.stringify(route.skipRateLimit)}`
|
||||
);
|
||||
}
|
||||
if (route.permission !== undefined && typeof route.permission !== 'string') {
|
||||
throw new TypeError(
|
||||
`${at} (${route.method} ${route.path}) — "permission" must be a string when provided, got: ${JSON.stringify(route.permission)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Freeze to prevent accidental mutation of route definitions at runtime.
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
export { routeRequest, requireAuth, requireAdmin } from './router.js';
|
||||
|
||||
// Runtime state — session resolver + feature routes registry
|
||||
export { configureRouter, getSessionResolver, clearRouterConfig, registerFeatureRoutes, getFeatureRoutes, clearFeatureRoutes } from './runtime.js';
|
||||
export { configureRouter, getSessionResolver, clearRouterConfig, registerApiRoutes, registerFeatureRoutes, getFeatureRoutes, clearFeatureRoutes } from './runtime.js';
|
||||
|
||||
// Response utilities — use in all handlers (core and modules)
|
||||
export { apiSuccess, apiError, getStatusCode } from './respond.js';
|
||||
|
||||
+11
-3
@@ -18,10 +18,10 @@
|
||||
* → handler(request, params, context)
|
||||
*/
|
||||
|
||||
import { cookies } from 'next/headers';
|
||||
import { getSessionCookieName } from '@zen/core/shared/config';
|
||||
import { checkRateLimit, getIpFromRequest, formatRetryAfter } from '@zen/core/shared/rate-limit';
|
||||
import { fail } from '@zen/core/shared/logger';
|
||||
import { hasPermission, PERMISSIONS } from '@zen/core/users';
|
||||
import { getCoreRoutes } from './core-routes.js';
|
||||
import { getFeatureRoutes, getSessionResolver } from './runtime.js';
|
||||
import { apiError } from './respond.js';
|
||||
@@ -39,6 +39,7 @@ const COOKIE_NAME = getSessionCookieName();
|
||||
* @returns {Promise<Object>} session
|
||||
*/
|
||||
export async function requireAuth() {
|
||||
const { cookies } = await import('next/headers');
|
||||
const cookieStore = await cookies();
|
||||
const sessionToken = cookieStore.get(COOKIE_NAME)?.value;
|
||||
|
||||
@@ -56,13 +57,14 @@ export async function requireAuth() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Exige une session admin valide. Lève une erreur si non authentifié ou non admin.
|
||||
* Exige une session avec la permission admin.access.
|
||||
* @returns {Promise<Object>} session
|
||||
*/
|
||||
export async function requireAdmin() {
|
||||
const session = await requireAuth();
|
||||
|
||||
if (session.user.role !== 'admin') {
|
||||
const allowed = await hasPermission(session.user.id, PERMISSIONS.ADMIN_ACCESS);
|
||||
if (!allowed) {
|
||||
throw new Error('Admin access required');
|
||||
}
|
||||
|
||||
@@ -269,6 +271,12 @@ export async function routeRequest(request, path) {
|
||||
try {
|
||||
if (matchedRoute.auth === 'admin') {
|
||||
context.session = await requireAdmin();
|
||||
if (matchedRoute.permission) {
|
||||
const allowed = await hasPermission(context.session.user.id, matchedRoute.permission);
|
||||
if (!allowed) {
|
||||
return apiError('Forbidden', 'Permission insuffisante');
|
||||
}
|
||||
}
|
||||
} else if (matchedRoute.auth === 'user') {
|
||||
context.session = await requireAuth();
|
||||
}
|
||||
|
||||
+11
-4
@@ -68,18 +68,25 @@ if (!globalThis[REGISTRY_KEY]) globalThis[REGISTRY_KEY] = [];
|
||||
const _featureRoutes = globalThis[REGISTRY_KEY];
|
||||
|
||||
/**
|
||||
* Enregistre les routes d'une feature core.
|
||||
* Appelé une fois par feature pendant initializeZen().
|
||||
* Enregistre des routes API.
|
||||
* Appelé une fois par feature core ou module externe pendant initializeZen()
|
||||
* ou depuis le hook register() d'un module.
|
||||
*
|
||||
* @param {ReadonlyArray} routes - Définitions produites par defineApiRoutes()
|
||||
*/
|
||||
export function registerFeatureRoutes(routes) {
|
||||
export function registerApiRoutes(routes) {
|
||||
if (!Array.isArray(routes)) {
|
||||
throw new TypeError('registerFeatureRoutes: routes must be an array');
|
||||
throw new TypeError('registerApiRoutes: routes must be an array');
|
||||
}
|
||||
_featureRoutes.push(...routes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Alias rétro-compatible de registerApiRoutes.
|
||||
* @deprecated Utiliser registerApiRoutes.
|
||||
*/
|
||||
export const registerFeatureRoutes = registerApiRoutes;
|
||||
|
||||
/**
|
||||
* Retourne toutes les routes de features enregistrées.
|
||||
* Appelé à chaque requête par le router pour construire la liste complète.
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
# Cron Framework
|
||||
|
||||
Ce répertoire est un **wrapper générique autour de `node-cron`**. Il ne connaît aucune tâche spécifique — les modules et features enregistrent leurs propres jobs. Ajouter un nouveau job ne nécessite jamais de modifier `src/core/cron/`.
|
||||
|
||||
---
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
src/core/cron/
|
||||
└── index.js schedule, stop, stopAll, trigger, validate, isRunning, getJobs, getStatus
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Import
|
||||
|
||||
```js
|
||||
import { schedule, stop, trigger } from '@zen/core/cron';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API
|
||||
|
||||
### `schedule(name, cronSchedule, handler, options?)`
|
||||
|
||||
Enregistre un job. Si un job du même nom existe déjà, il est stoppé et remplacé.
|
||||
|
||||
```js
|
||||
schedule('daily-report', '0 9 * * *', async () => {
|
||||
await sendReport();
|
||||
});
|
||||
|
||||
schedule('every-5min', '*/5 * * * *', async () => {
|
||||
await syncData();
|
||||
}, { timezone: 'America/New_York', runOnInit: true });
|
||||
```
|
||||
|
||||
| Paramètre | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `name` | `string` | Nom unique du job |
|
||||
| `cronSchedule` | `string` | Expression cron (5 ou 6 champs) |
|
||||
| `handler` | `async Function` | Fonction exécutée à chaque déclenchement |
|
||||
| `options.timezone` | `string` | Timezone IANA (défaut : `ZEN_TIMEZONE` ou `America/Toronto`) |
|
||||
| `options.runOnInit` | `boolean` | Exécuter immédiatement à l'enregistrement (défaut : `false`) |
|
||||
|
||||
Retourne l'instance `node-cron` task.
|
||||
|
||||
---
|
||||
|
||||
### `stop(name)`
|
||||
|
||||
Stoppe et supprime un job par son nom. Retourne `true` si le job existait, `false` sinon.
|
||||
|
||||
### `stopAll()`
|
||||
|
||||
Stoppe et supprime tous les jobs enregistrés.
|
||||
|
||||
### `trigger(name)`
|
||||
|
||||
Déclenche manuellement un job sans attendre son prochain tick. Lève une `Error` si le job n'existe pas.
|
||||
|
||||
```js
|
||||
await trigger('daily-report');
|
||||
```
|
||||
|
||||
### `validate(expression)`
|
||||
|
||||
Valide une expression cron. Retourne `boolean`.
|
||||
|
||||
### `isRunning(name)`
|
||||
|
||||
Vérifie si un job est actuellement enregistré. Retourne `boolean`.
|
||||
|
||||
### `getJobs()`
|
||||
|
||||
Retourne la liste des noms de tous les jobs enregistrés (`string[]`).
|
||||
|
||||
### `getStatus()`
|
||||
|
||||
Retourne les métadonnées de tous les jobs enregistrés.
|
||||
|
||||
```js
|
||||
{
|
||||
'daily-report': {
|
||||
schedule: '0 9 * * *',
|
||||
timezone: 'America/Toronto',
|
||||
registeredAt: '2026-04-24T09:00:00.000Z'
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Enregistrer un job depuis un module
|
||||
|
||||
Les jobs vivent **avec leur feature ou module**, pas dans le framework. Enregistrer un job dans `initializeZen()` (`src/shared/lib/init.js`) :
|
||||
|
||||
```js
|
||||
// src/modules/mymodule/cron.js
|
||||
import { schedule } from '@zen/core/cron';
|
||||
|
||||
export function registerCronJobs() {
|
||||
schedule('mymodule-sync', '*/15 * * * *', async () => {
|
||||
await syncMyModule();
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
```js
|
||||
// src/shared/lib/init.js
|
||||
import { registerCronJobs } from '../../modules/mymodule/cron.js';
|
||||
|
||||
registerCronJobs();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Comportement Hot-Reload
|
||||
|
||||
Les jobs sont stockés dans `globalThis[Symbol.for('__ZEN_CRON_JOBS__')]` — un store partagé qui survit aux invalidations de cache de modules de Next.js. Un job enregistré deux fois (hot-reload) remplace silencieusement l'ancien plutôt que de créer un doublon.
|
||||
|
||||
---
|
||||
|
||||
## Gestion des erreurs
|
||||
|
||||
Les erreurs levées par un handler sont interceptées et loguées via `fail()` — elles ne font jamais crasher le processus.
|
||||
|
||||
```
|
||||
✗ Cron daily-report: Connection timeout
|
||||
```
|
||||
@@ -81,11 +81,11 @@ export function schedule(name, cronSchedule, handler, options = {}) {
|
||||
} catch (error) {
|
||||
fail(`Cron ${name}: ${error.message}`);
|
||||
}
|
||||
}, {
|
||||
scheduled: true,
|
||||
timezone,
|
||||
runOnInit: options.runOnInit ?? false
|
||||
});
|
||||
}, { timezone });
|
||||
|
||||
if (options.runOnInit) {
|
||||
handler().catch((error) => fail(`Cron ${name}: ${error.message}`));
|
||||
}
|
||||
|
||||
jobs.set(name, {
|
||||
job,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* 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.
|
||||
|
||||
@@ -14,7 +14,7 @@ export {
|
||||
closePool,
|
||||
testConnection,
|
||||
tableExists
|
||||
} from './db.js';
|
||||
} from './db.server.js';
|
||||
|
||||
// CRUD helper functions
|
||||
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`.
|
||||
@@ -1 +1 @@
|
||||
export { BaseLayout } from './BaseLayout.jsx';
|
||||
export { BaseLayout } from './BaseLayout.js';
|
||||
|
||||
@@ -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()];
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
# Storage
|
||||
|
||||
Ce répertoire est le **module de stockage de fichiers**. Il fournit une couche d'accès générique aux providers S3-compatibles (Cloudflare R2, Backblaze B2) : upload, téléchargement, suppression, listage, URL présignées et validation de fichiers. Il gère également le système de contrôle d'accès aux fichiers servis via HTTP.
|
||||
|
||||
Il ne connaît aucune feature spécifique — les features l'importent pour leurs propres besoins et enregistrent leurs propres policies d'accès.
|
||||
|
||||
---
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
src/core/storage/
|
||||
├── index.js Exports publics (uploadFile, deleteFile, validateUpload…)
|
||||
├── api.js Route HTTP /zen/api/storage/** avec contrôle d'accès par chemin
|
||||
├── storage-config.js Enregistrement des policies et préfixes publics
|
||||
├── utils.js Validation de fichiers, MIME types, magic bytes
|
||||
├── signing.js Signature AWS Signature V4 pour requêtes S3-compatibles
|
||||
├── cloudflare-r2.js Configuration provider Cloudflare R2
|
||||
└── backblaze.js Configuration provider Backblaze B2
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Variables d'environnement
|
||||
|
||||
| Variable | Rôle |
|
||||
|----------|------|
|
||||
| `ZEN_STORAGE_PROVIDER` | Provider à utiliser : `r2` (défaut) ou `backblaze` |
|
||||
| `ZEN_R2_ACCOUNT_ID` | ID de compte Cloudflare (R2) |
|
||||
| `ZEN_R2_ACCESS_KEY_ID` | Clé d'accès R2 |
|
||||
| `ZEN_R2_SECRET_ACCESS_KEY` | Secret R2 |
|
||||
| `ZEN_R2_BUCKET_NAME` | Nom du bucket R2 |
|
||||
| `ZEN_B2_KEY_ID` | ID de clé Backblaze B2 |
|
||||
| `ZEN_B2_APPLICATION_KEY` | Clé d'application Backblaze B2 |
|
||||
| `ZEN_B2_BUCKET_NAME` | Nom du bucket Backblaze B2 |
|
||||
| `ZEN_B2_ENDPOINT` | Endpoint S3 Backblaze (ex: `s3.us-west-004.backblazeb2.com`) |
|
||||
|
||||
---
|
||||
|
||||
## Système de contrôle d'accès
|
||||
|
||||
### Principe : tout est privé par défaut
|
||||
|
||||
Aucun fichier n'est accessible sans configuration explicite. Pour qu'un fichier soit servi via `/zen/api/storage/**`, son chemin doit correspondre à l'un des deux mécanismes déclarés pendant `initializeZen()` : un préfixe public ou une policy d'accès.
|
||||
|
||||
### Préfixes publics
|
||||
|
||||
Les fichiers dont le chemin commence par un préfixe public sont servis **sans authentification**. Utile pour les assets publics (logos, images de produits…).
|
||||
|
||||
Le chemin doit avoir au minimum la forme `{prefixe}/{id}/{fichier}` — un préfixe seul ne suffit pas à exposer des fichiers.
|
||||
|
||||
```js
|
||||
// src/features/myfeature/storage-policies.js
|
||||
export const storagePublicPrefixes = ['products'];
|
||||
// products/abc123/photo.webp → accessible sans session
|
||||
```
|
||||
|
||||
### Policies d'accès (chemins privés)
|
||||
|
||||
Pour les chemins privés, une policy doit correspondre au premier segment du chemin (`pathParts[0]`). Deux types sont disponibles :
|
||||
|
||||
| Type | Règle |
|
||||
|------|-------|
|
||||
| `owner` | `pathParts[1]` doit être l'ID de l'utilisateur connecté. Les admins ont accès à tout. |
|
||||
| `admin` | L'utilisateur doit avoir le rôle `admin`. |
|
||||
|
||||
```js
|
||||
// src/features/myfeature/storage-policies.js
|
||||
export const storageAccessPolicies = [
|
||||
{ prefix: 'users', type: 'owner' }, // users/{userId}/...
|
||||
{ prefix: 'invoices', type: 'admin' }, // invoices/...
|
||||
];
|
||||
```
|
||||
|
||||
### Enregistrement dans initializeZen()
|
||||
|
||||
```js
|
||||
// src/shared/lib/init.js
|
||||
import { registerStoragePolicies, registerStoragePublicPrefixes } from '@zen/core/storage';
|
||||
import { storageAccessPolicies, storagePublicPrefixes } from '../../features/myfeature/storage-policies.js';
|
||||
|
||||
registerStoragePolicies(storageAccessPolicies);
|
||||
registerStoragePublicPrefixes(storagePublicPrefixes); // si nécessaire
|
||||
```
|
||||
|
||||
L'enregistrement est **additif** — plusieurs features peuvent appeler `registerStoragePolicies` indépendamment.
|
||||
|
||||
---
|
||||
|
||||
## Fonctions d'opération
|
||||
|
||||
Toutes les fonctions retournent un objet `{ success, data, error }`. Elles ne lèvent jamais d'exception — les erreurs sont journalisées côté serveur et encapsulées dans `error`.
|
||||
|
||||
### `uploadFile({ key, body, contentType, metadata?, cacheControl? })`
|
||||
|
||||
Upload générique.
|
||||
|
||||
```js
|
||||
const result = await uploadFile({
|
||||
key: 'users/abc123/profile/avatar_xxx.webp',
|
||||
body: buffer,
|
||||
contentType: 'image/webp',
|
||||
metadata: { userId: 'abc123' },
|
||||
});
|
||||
if (!result.success) throw new Error(result.error);
|
||||
```
|
||||
|
||||
### `uploadImage({ key, body, contentType, metadata? })`
|
||||
|
||||
Équivalent à `uploadFile` avec `Cache-Control: public, max-age=31536000` (1 an).
|
||||
|
||||
### `deleteFile(key)`
|
||||
|
||||
Supprime un fichier.
|
||||
|
||||
```js
|
||||
await deleteFile('users/abc123/profile/avatar_xxx.webp');
|
||||
```
|
||||
|
||||
### `deleteFiles(keys[])`
|
||||
|
||||
Suppression en lot (S3 DeleteObjects). Retourne les clés supprimées et les erreurs éventuelles.
|
||||
|
||||
```js
|
||||
const { data } = await deleteFiles(['a.jpg', 'b.jpg']);
|
||||
// data.deleted → [{ Key: 'a.jpg' }, …]
|
||||
// data.errors → []
|
||||
```
|
||||
|
||||
### `getFile(key)`
|
||||
|
||||
Récupère le contenu et les métadonnées d'un fichier.
|
||||
|
||||
```js
|
||||
const { data } = await getFile('users/abc123/profile/avatar.webp');
|
||||
// data.body, data.contentType, data.contentLength, data.lastModified, data.metadata
|
||||
```
|
||||
|
||||
### `getFileMetadata(key)`
|
||||
|
||||
Requête HEAD : métadonnées uniquement, sans télécharger le contenu.
|
||||
|
||||
### `fileExists(key)`
|
||||
|
||||
Retourne `true` si le fichier existe.
|
||||
|
||||
```js
|
||||
if (await fileExists('users/abc123/profile/avatar.webp')) { … }
|
||||
```
|
||||
|
||||
### `listFiles({ prefix?, maxKeys?, continuationToken? })`
|
||||
|
||||
Liste les fichiers avec pagination (max 1 000 par appel).
|
||||
|
||||
```js
|
||||
const { data } = await listFiles({ prefix: 'users/abc123/', maxKeys: 100 });
|
||||
// data.files → [{ key, size, lastModified, etag }, …]
|
||||
// data.isTruncated, data.nextContinuationToken
|
||||
```
|
||||
|
||||
### `getPresignedUrl({ key, expiresIn?, operation? })`
|
||||
|
||||
Génère une URL signée temporaire. `expiresIn` en secondes (défaut 3600, max 604 800 = 7 jours). `operation` : `'get'` (défaut) ou `'put'`.
|
||||
|
||||
```js
|
||||
const { data } = await getPresignedUrl({ key: 'docs/contrat.pdf', expiresIn: 300 });
|
||||
// data.url → URL signée valide 5 minutes
|
||||
```
|
||||
|
||||
### `copyFile({ sourceKey, destinationKey })`
|
||||
|
||||
Copie un fichier dans le même bucket.
|
||||
|
||||
### `moveFile({ sourceKey, destinationKey })`
|
||||
|
||||
Copie puis supprime la source. Si la suppression échoue, le fichier copié est conservé (non-fatal).
|
||||
|
||||
### `proxyFile(key, { filename? })`
|
||||
|
||||
Récupère un fichier pour le streamer directement au client.
|
||||
|
||||
---
|
||||
|
||||
## Validation de fichiers
|
||||
|
||||
### `validateUpload({ filename, size, allowedTypes, maxSize, buffer? })`
|
||||
|
||||
Validation complète avant upload. Retourne `{ valid, errors[] }`.
|
||||
|
||||
```js
|
||||
const { valid, errors } = validateUpload({
|
||||
filename: file.name,
|
||||
size: buffer.byteLength,
|
||||
allowedTypes: FILE_TYPE_PRESETS.IMAGES,
|
||||
maxSize: FILE_SIZE_LIMITS.AVATAR,
|
||||
buffer,
|
||||
});
|
||||
if (!valid) return apiError('Bad Request', errors[0]);
|
||||
```
|
||||
|
||||
Couches de validation appliquées dans l'ordre :
|
||||
1. Nom de fichier requis
|
||||
2. SVG explicitement interdit (risque d'exécution de scripts)
|
||||
3. Extension contre la liste blanche `allowedTypes`
|
||||
4. Taille contre `maxSize`
|
||||
5. *(si buffer fourni)* Magic bytes — assertion positive que l'extension correspond au contenu réel
|
||||
6. *(si buffer fourni)* Scan des 512 premiers octets pour HTML/SVG/XML (défense contre les fichiers polyglots)
|
||||
|
||||
### `FILE_TYPE_PRESETS`
|
||||
|
||||
| Constante | Extensions |
|
||||
|-----------|-----------|
|
||||
| `IMAGES` | `.jpg` `.jpeg` `.png` `.gif` `.webp` |
|
||||
| `IMAGES_NO_GIF` | `.jpg` `.jpeg` `.png` `.webp` |
|
||||
| `DOCUMENTS` | `.pdf` `.doc` `.docx` `.xls` `.xlsx` `.ppt` `.pptx` `.txt` `.csv` |
|
||||
| `PDF_ONLY` | `.pdf` |
|
||||
| `VIDEOS` | `.mp4` `.avi` `.mov` `.wmv` |
|
||||
| `AUDIO` | `.mp3` `.wav` |
|
||||
| `ARCHIVES` | `.zip` `.rar` `.7z` `.tar` `.gz` |
|
||||
|
||||
### `FILE_SIZE_LIMITS`
|
||||
|
||||
| Constante | Limite |
|
||||
|-----------|--------|
|
||||
| `AVATAR` | 5 MB |
|
||||
| `IMAGE` | 10 MB |
|
||||
| `DOCUMENT` | 50 MB |
|
||||
| `VIDEO` | 500 MB |
|
||||
| `LARGE_FILE` | 1 GB |
|
||||
|
||||
### Fonctions utilitaires
|
||||
|
||||
| Fonction | Description |
|
||||
|----------|-------------|
|
||||
| `generateUniqueFilename(originalName, prefix?)` | Génère `{prefix}_{timestamp}_{hash}{ext}` |
|
||||
| `getFileExtension(filename)` | Retourne l'extension avec le point (`.jpg`) ou `''` |
|
||||
| `getMimeType(filename)` | Retourne le MIME type depuis l'extension |
|
||||
| `validateFileType(filename, allowedTypes)` | Vérifie l'extension contre une liste |
|
||||
| `validateFileSize(size, maxSize)` | Vérifie la taille |
|
||||
| `formatFileSize(bytes)` | Formate en lisible humain (`1.5 MB`) |
|
||||
| `sanitizeFilename(filename)` | Supprime les caractères spéciaux |
|
||||
|
||||
---
|
||||
|
||||
## Usage depuis une feature
|
||||
|
||||
```js
|
||||
// src/features/media/api.js
|
||||
import { uploadImage, deleteFile, validateUpload, FILE_TYPE_PRESETS, FILE_SIZE_LIMITS, generateUniqueFilename } from '../../core/storage/index.js';
|
||||
|
||||
export async function uploadAvatar(userId, file, buffer) {
|
||||
const { valid, errors } = validateUpload({
|
||||
filename: file.name,
|
||||
size: buffer.byteLength,
|
||||
allowedTypes: FILE_TYPE_PRESETS.IMAGES,
|
||||
maxSize: FILE_SIZE_LIMITS.AVATAR,
|
||||
buffer,
|
||||
});
|
||||
if (!valid) throw new Error(errors[0]);
|
||||
|
||||
const filename = generateUniqueFilename(file.name, 'avatar');
|
||||
const key = `users/${userId}/profile/${filename}`;
|
||||
|
||||
const result = await uploadImage({ key, body: buffer, contentType: getMimeType(file.name) });
|
||||
if (!result.success) throw new Error('Upload failed');
|
||||
|
||||
return key;
|
||||
}
|
||||
```
|
||||
|
||||
```js
|
||||
// src/features/media/storage-policies.js ← la feature déclare ses propres policies
|
||||
export const storageAccessPolicies = [
|
||||
{ prefix: 'users', type: 'owner' },
|
||||
];
|
||||
```
|
||||
|
||||
## Usage depuis un module
|
||||
|
||||
```js
|
||||
// src/modules/mymodule/actions.js
|
||||
import { uploadFile, deleteFile, generateUniqueFilename } from '@zen/core/storage';
|
||||
```
|
||||
@@ -10,12 +10,11 @@
|
||||
* - All other paths → session required; access governed by registered policies
|
||||
* - Unknown paths → denied
|
||||
*
|
||||
* Call configureStorageApi({ getPublicPrefixes, getAccessPolicies }) during
|
||||
* Call registerStoragePolicies() and registerStoragePublicPrefixes() during
|
||||
* initializeZen before the first request, following the same pattern as
|
||||
* configureRouter in core/api/runtime.js.
|
||||
* registerFeatureRoutes in core/api/runtime.js.
|
||||
*/
|
||||
|
||||
import { cookies } from 'next/headers';
|
||||
import { getSessionCookieName } from '@zen/core/shared/config';
|
||||
import { getSessionResolver } from '../api/router.js';
|
||||
import { getFile } from './index.js';
|
||||
@@ -60,6 +59,7 @@ async function handleGetFile(_request, { wildcard: fileKey }) {
|
||||
}
|
||||
|
||||
// Require authentication for all other paths.
|
||||
const { cookies } = await import('next/headers');
|
||||
const cookieStore = await cookies();
|
||||
const sessionToken = cookieStore.get(COOKIE_NAME)?.value;
|
||||
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
/**
|
||||
* Backblaze B2 provider config (S3-compatible API).
|
||||
* Reads ZEN_STORAGE_B2_* environment variables.
|
||||
* Reads ZEN_STORAGE_* environment variables.
|
||||
*
|
||||
* Endpoint format: s3.<region>.backblazeb2.com (e.g. s3.us-west-004.backblazeb2.com)
|
||||
*/
|
||||
|
||||
export function getConfig() {
|
||||
const host = process.env.ZEN_STORAGE_B2_ENDPOINT;
|
||||
const region = process.env.ZEN_STORAGE_B2_REGION;
|
||||
const accessKeyId = process.env.ZEN_STORAGE_B2_ACCESS_KEY;
|
||||
const secretAccessKey = process.env.ZEN_STORAGE_B2_SECRET_KEY;
|
||||
const bucket = process.env.ZEN_STORAGE_B2_BUCKET;
|
||||
const host = process.env.ZEN_STORAGE_ENDPOINT;
|
||||
const region = process.env.ZEN_STORAGE_REGION;
|
||||
const accessKeyId = process.env.ZEN_STORAGE_ACCESS_KEY;
|
||||
const secretAccessKey = process.env.ZEN_STORAGE_SECRET_KEY;
|
||||
const bucket = process.env.ZEN_STORAGE_BUCKET;
|
||||
|
||||
if (!host || !accessKeyId || !secretAccessKey) {
|
||||
throw new Error(
|
||||
'Backblaze B2 credentials are not configured. Please set ZEN_STORAGE_B2_ENDPOINT, ZEN_STORAGE_B2_ACCESS_KEY, and ZEN_STORAGE_B2_SECRET_KEY.'
|
||||
'Backblaze B2 credentials are not configured. Please set ZEN_STORAGE_ENDPOINT, ZEN_STORAGE_ACCESS_KEY, and ZEN_STORAGE_SECRET_KEY.'
|
||||
);
|
||||
}
|
||||
if (!bucket) {
|
||||
throw new Error('ZEN_STORAGE_B2_BUCKET environment variable is not set.');
|
||||
throw new Error('ZEN_STORAGE_BUCKET environment variable is not set.');
|
||||
}
|
||||
if (!region) {
|
||||
throw new Error('ZEN_STORAGE_B2_REGION environment variable is not set.');
|
||||
throw new Error('ZEN_STORAGE_REGION environment variable is not set.');
|
||||
}
|
||||
|
||||
return { accessKeyId, secretAccessKey, bucket, host, region };
|
||||
|
||||
@@ -350,4 +350,4 @@ export {
|
||||
moveFile,
|
||||
};
|
||||
|
||||
export { configureStorageApi } from './storage-config.js';
|
||||
export { registerStoragePolicies, registerStoragePublicPrefixes, clearStorageConfig } from './storage-config.js';
|
||||
|
||||
@@ -1,21 +1,44 @@
|
||||
/**
|
||||
* Storage API runtime configuration.
|
||||
* Holds injected prefix/policy resolvers — same pattern as core/api/runtime.js.
|
||||
* Imported by both api.js (reads) and index.js (exports configureStorageApi).
|
||||
*
|
||||
* Additive registration — mirrors core/api/runtime.js:
|
||||
* registerStoragePolicies(policies) called by features during initializeZen()
|
||||
* registerStoragePublicPrefixes(prefixes) called by features during initializeZen()
|
||||
* clearStorageConfig() called by resetZenInitialization() / tests
|
||||
*
|
||||
* getStorageAccessPolicies() and getStoragePublicPrefixes() are read-only readers
|
||||
* used internally by api.js — they are not re-exported from index.js.
|
||||
*/
|
||||
|
||||
let _getPublicPrefixes = () => [];
|
||||
let _getAccessPolicies = () => [];
|
||||
const POLICIES_KEY = Symbol.for('__ZEN_STORAGE_POLICIES__');
|
||||
const PREFIXES_KEY = Symbol.for('__ZEN_STORAGE_PUBLIC_PREFIXES__');
|
||||
|
||||
export function configureStorageApi({ getPublicPrefixes, getAccessPolicies }) {
|
||||
_getPublicPrefixes = getPublicPrefixes;
|
||||
_getAccessPolicies = getAccessPolicies;
|
||||
if (!globalThis[POLICIES_KEY]) globalThis[POLICIES_KEY] = [];
|
||||
if (!globalThis[PREFIXES_KEY]) globalThis[PREFIXES_KEY] = [];
|
||||
|
||||
export function registerStoragePolicies(policies) {
|
||||
if (!Array.isArray(policies)) {
|
||||
throw new TypeError('registerStoragePolicies: policies must be an array');
|
||||
}
|
||||
globalThis[POLICIES_KEY].push(...policies);
|
||||
}
|
||||
|
||||
export function registerStoragePublicPrefixes(prefixes) {
|
||||
if (!Array.isArray(prefixes)) {
|
||||
throw new TypeError('registerStoragePublicPrefixes: prefixes must be an array');
|
||||
}
|
||||
globalThis[PREFIXES_KEY].push(...prefixes);
|
||||
}
|
||||
|
||||
export function clearStorageConfig() {
|
||||
globalThis[POLICIES_KEY].length = 0;
|
||||
globalThis[PREFIXES_KEY].length = 0;
|
||||
}
|
||||
|
||||
export function getStoragePublicPrefixes() {
|
||||
return _getPublicPrefixes();
|
||||
return globalThis[PREFIXES_KEY];
|
||||
}
|
||||
|
||||
export function getStorageAccessPolicies() {
|
||||
return _getAccessPolicies();
|
||||
return globalThis[POLICIES_KEY];
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
```
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Sun01Icon, Moon02Icon, SunCloud02Icon, MoonCloudIcon } from '@zen/core/shared/icons';
|
||||
import { Sun01Icon, Moon02Icon, SunCloud01Icon, MoonCloudIcon } from '@zen/core/shared/icons';
|
||||
|
||||
// Script à injecter dans <head> pour appliquer le thème avant le premier rendu (anti-FOUC).
|
||||
export const THEME_INIT_SCRIPT = `(function(){try{var t=localStorage.getItem('theme'),d=window.matchMedia('(prefers-color-scheme: dark)').matches;if(t==='dark'||(!t&&d))document.documentElement.classList.add('dark');}catch(e){}})();`;
|
||||
@@ -32,7 +32,7 @@ export function applyTheme(theme) {
|
||||
}
|
||||
|
||||
export function getThemeIcon(theme, systemIsDark) {
|
||||
if (theme === 'auto') return systemIsDark ? MoonCloudIcon : SunCloud02Icon;
|
||||
if (theme === 'auto') return systemIsDark ? MoonCloudIcon : SunCloud01Icon;
|
||||
return THEME_ICONS[theme];
|
||||
}
|
||||
|
||||
@@ -43,6 +43,20 @@ function getNextTheme(current) {
|
||||
return systemIsDark ? 'dark' : 'auto';
|
||||
}
|
||||
|
||||
export function ThemeWatcher() {
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
function onSystemChange(e) {
|
||||
if (localStorage.getItem('theme')) return;
|
||||
document.documentElement.classList.toggle('dark', e.matches);
|
||||
}
|
||||
mq.addEventListener('change', onSystemChange);
|
||||
return () => mq.removeEventListener('change', onSystemChange);
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const [theme, setTheme] = useState('auto');
|
||||
const [systemIsDark, setSystemIsDark] = useState(false);
|
||||
@@ -54,8 +68,6 @@ export function useTheme() {
|
||||
const mq = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
function onSystemChange(e) {
|
||||
setSystemIsDark(e.matches);
|
||||
if (localStorage.getItem('theme')) return;
|
||||
document.documentElement.classList.toggle('dark', e.matches);
|
||||
}
|
||||
mq.addEventListener('change', onSystemChange);
|
||||
return () => mq.removeEventListener('change', onSystemChange);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
```
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import React, { createContext, useContext, useState, useCallback } from 'react';
|
||||
import React, { createContext, useContext, useState, useCallback, useMemo } from 'react';
|
||||
|
||||
const ToastContext = createContext();
|
||||
|
||||
@@ -88,7 +88,7 @@ export const ToastProvider = ({ children }) => {
|
||||
});
|
||||
}, [addToast]);
|
||||
|
||||
const value = {
|
||||
const value = useMemo(() => ({
|
||||
toasts,
|
||||
addToast,
|
||||
removeToast,
|
||||
@@ -97,7 +97,7 @@ export const ToastProvider = ({ children }) => {
|
||||
error,
|
||||
warning,
|
||||
info,
|
||||
};
|
||||
}), [toasts, addToast, removeToast, clearAllToasts, success, error, warning, info]);
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={value}>
|
||||
|
||||
@@ -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 |
|
||||
@@ -1,36 +1,20 @@
|
||||
/**
|
||||
* Authentication Logic
|
||||
* Main authentication functions for user registration, login, and password management
|
||||
*/
|
||||
|
||||
import { create, findOne, updateById, count } from '@zen/core/database';
|
||||
import { query, create, findOne, updateById, count } from '@zen/core/database';
|
||||
import { hashPassword, verifyPassword, generateId } from './password.js';
|
||||
import { createSession } from './session.js';
|
||||
import { fail } from '@zen/core/shared/logger';
|
||||
import { createEmailVerification, createPasswordReset, verifyResetToken, deleteResetToken, sendPasswordChangedEmail } from './email.js';
|
||||
import { createEmailVerification, createPasswordReset, verifyResetToken, deleteResetToken, verifyAccountSetupToken, deleteAccountSetupToken } from './verifications.js';
|
||||
|
||||
/**
|
||||
* Register a new user
|
||||
* @param {Object} userData - User registration data
|
||||
* @param {string} userData.email - User email
|
||||
* @param {string} userData.password - User password
|
||||
* @param {string} userData.name - User name
|
||||
* @returns {Promise<Object>} Created user and session
|
||||
*/
|
||||
async function register(userData) {
|
||||
async function register(userData, { onEmailVerification } = {}) {
|
||||
const { email, password, name } = userData;
|
||||
|
||||
// Validate required fields
|
||||
if (!email || !password || !name) {
|
||||
throw new Error('L\'e-mail, le mot de passe et le nom sont requis');
|
||||
}
|
||||
|
||||
// Validate email length (maximum 254 characters - RFC standard)
|
||||
if (email.length > 254) {
|
||||
throw new Error('L\'e-mail doit contenir 254 caractères ou moins');
|
||||
}
|
||||
|
||||
// Validate password length (minimum 8, maximum 128 characters)
|
||||
if (password.length < 8) {
|
||||
throw new Error('Le mot de passe doit contenir au moins 8 caractères');
|
||||
}
|
||||
@@ -39,7 +23,6 @@ async function register(userData) {
|
||||
throw new Error('Le mot de passe doit contenir 128 caractères ou moins');
|
||||
}
|
||||
|
||||
// Validate password complexity (1 uppercase, 1 lowercase, 1 number)
|
||||
const hasUppercase = /[A-Z]/.test(password);
|
||||
const hasLowercase = /[a-z]/.test(password);
|
||||
const hasNumber = /\d/.test(password);
|
||||
@@ -48,31 +31,25 @@ async function register(userData) {
|
||||
throw new Error('Le mot de passe doit contenir au moins une majuscule, une minuscule et un chiffre');
|
||||
}
|
||||
|
||||
// Validate name length (maximum 100 characters)
|
||||
if (name.length > 100) {
|
||||
throw new Error('Le nom doit contenir 100 caractères ou moins');
|
||||
}
|
||||
|
||||
// Validate name is not empty after trimming
|
||||
if (name.trim().length === 0) {
|
||||
throw new Error('Le nom ne peut pas être vide');
|
||||
}
|
||||
|
||||
// Check if user already exists
|
||||
const existingUser = await findOne('zen_auth_users', { email });
|
||||
if (existingUser) {
|
||||
throw new Error('Un utilisateur avec cet e-mail existe déjà');
|
||||
}
|
||||
|
||||
// Check if this is the first user - if so, make them admin
|
||||
const userCount = await count('zen_auth_users');
|
||||
const role = userCount === 0 ? 'admin' : 'user';
|
||||
|
||||
// Hash password
|
||||
const hashedPassword = await hashPassword(password);
|
||||
|
||||
// Create user
|
||||
const userId = generateId();
|
||||
|
||||
const user = await create('zen_auth_users', {
|
||||
id: userId,
|
||||
email,
|
||||
@@ -83,7 +60,6 @@ async function register(userData) {
|
||||
updated_at: new Date()
|
||||
});
|
||||
|
||||
// Create account with password
|
||||
const accountId = generateId();
|
||||
await create('zen_auth_accounts', {
|
||||
id: accountId,
|
||||
@@ -94,38 +70,46 @@ async function register(userData) {
|
||||
updated_at: new Date()
|
||||
});
|
||||
|
||||
// Create email verification token
|
||||
// Assign admin role to first user via the new roles system
|
||||
if (role === 'admin') {
|
||||
try {
|
||||
const adminRole = await query(`SELECT id FROM zen_auth_roles WHERE name = 'admin'`);
|
||||
if (adminRole.rows.length > 0) {
|
||||
await query(
|
||||
`INSERT INTO zen_auth_user_roles (user_id, role_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
|
||||
[user.id, adminRole.rows[0].id]
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
fail(`register: failed to assign admin role to first user: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
const verification = await createEmailVerification(email);
|
||||
|
||||
return {
|
||||
user,
|
||||
verificationToken: verification.token
|
||||
};
|
||||
if (onEmailVerification) {
|
||||
try {
|
||||
await onEmailVerification(email, verification.token);
|
||||
} catch (err) {
|
||||
fail(`register: failed to send verification email: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
return { user, verificationToken: verification.token };
|
||||
}
|
||||
|
||||
/**
|
||||
* Login a user
|
||||
* @param {Object} credentials - Login credentials
|
||||
* @param {string} credentials.email - User email
|
||||
* @param {string} credentials.password - User password
|
||||
* @param {Object} sessionOptions - Session options (ipAddress, userAgent)
|
||||
* @returns {Promise<Object>} User and session
|
||||
*/
|
||||
async function login(credentials, sessionOptions = {}) {
|
||||
const { email, password } = credentials;
|
||||
|
||||
// Validate required fields
|
||||
if (!email || !password) {
|
||||
throw new Error('L\'e-mail et le mot de passe sont requis');
|
||||
}
|
||||
|
||||
// Find user
|
||||
const user = await findOne('zen_auth_users', { email });
|
||||
if (!user) {
|
||||
throw new Error('E-mail ou mot de passe incorrect');
|
||||
}
|
||||
|
||||
// Find account with password
|
||||
const account = await findOne('zen_auth_accounts', {
|
||||
user_id: user.id,
|
||||
provider_id: 'credential'
|
||||
@@ -135,65 +119,38 @@ async function login(credentials, sessionOptions = {}) {
|
||||
throw new Error('E-mail ou mot de passe incorrect');
|
||||
}
|
||||
|
||||
// Verify password
|
||||
const isValid = await verifyPassword(password, account.password);
|
||||
if (!isValid) {
|
||||
throw new Error('E-mail ou mot de passe incorrect');
|
||||
}
|
||||
|
||||
// Create session
|
||||
const session = await createSession(user.id, sessionOptions);
|
||||
|
||||
return {
|
||||
user,
|
||||
session
|
||||
};
|
||||
return { user, session };
|
||||
}
|
||||
|
||||
/**
|
||||
* Request a password reset
|
||||
* @param {string} email - User email
|
||||
* @returns {Promise<Object>} Reset token
|
||||
*/
|
||||
async function requestPasswordReset(email) {
|
||||
// Validate email
|
||||
if (!email) {
|
||||
throw new Error('L\'e-mail est requis');
|
||||
}
|
||||
|
||||
// Check if user exists
|
||||
const user = await findOne('zen_auth_users', { email });
|
||||
if (!user) {
|
||||
// Don't reveal if user exists or not
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// Create password reset token
|
||||
const reset = await createPasswordReset(email);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
token: reset.token
|
||||
};
|
||||
return { success: true, token: reset.token };
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset password with token
|
||||
* @param {Object} resetData - Reset data
|
||||
* @param {string} resetData.email - User email
|
||||
* @param {string} resetData.token - Reset token
|
||||
* @param {string} resetData.newPassword - New password
|
||||
* @returns {Promise<Object>} Success status
|
||||
*/
|
||||
async function resetPassword(resetData) {
|
||||
async function resetPassword(resetData, { onPasswordChanged } = {}) {
|
||||
const { email, token, newPassword } = resetData;
|
||||
|
||||
// Validate required fields
|
||||
if (!email || !token || !newPassword) {
|
||||
throw new Error('L\'e-mail, le jeton et le nouveau mot de passe sont requis');
|
||||
}
|
||||
|
||||
// Validate password length (minimum 8, maximum 128 characters)
|
||||
if (newPassword.length < 8) {
|
||||
throw new Error('Le mot de passe doit contenir au moins 8 caractères');
|
||||
}
|
||||
@@ -202,7 +159,6 @@ async function resetPassword(resetData) {
|
||||
throw new Error('Le mot de passe doit contenir 128 caractères ou moins');
|
||||
}
|
||||
|
||||
// Validate password complexity (1 uppercase, 1 lowercase, 1 number)
|
||||
const hasUppercase = /[A-Z]/.test(newPassword);
|
||||
const hasLowercase = /[a-z]/.test(newPassword);
|
||||
const hasNumber = /\d/.test(newPassword);
|
||||
@@ -211,21 +167,16 @@ async function resetPassword(resetData) {
|
||||
throw new Error('Le mot de passe doit contenir au moins une majuscule, une minuscule et un chiffre');
|
||||
}
|
||||
|
||||
// Authoritative token verification — this check must live here so that any
|
||||
// caller that imports resetPassword() directly (bypassing the server-action
|
||||
// layer) cannot reset a password with an arbitrary or omitted token.
|
||||
const tokenValid = await verifyResetToken(email, token);
|
||||
if (!tokenValid) {
|
||||
throw new Error('Jeton de réinitialisation invalide ou expiré');
|
||||
}
|
||||
|
||||
// Find user
|
||||
const user = await findOne('zen_auth_users', { email });
|
||||
if (!user) {
|
||||
throw new Error('Jeton de réinitialisation invalide');
|
||||
}
|
||||
|
||||
// Find account
|
||||
const account = await findOne('zen_auth_accounts', {
|
||||
user_id: user.id,
|
||||
provider_id: 'credential'
|
||||
@@ -235,34 +186,26 @@ async function resetPassword(resetData) {
|
||||
throw new Error('Compte introuvable');
|
||||
}
|
||||
|
||||
// Hash new password
|
||||
const hashedPassword = await hashPassword(newPassword);
|
||||
|
||||
// Update password
|
||||
await updateById('zen_auth_accounts', account.id, {
|
||||
password: hashedPassword,
|
||||
updated_at: new Date()
|
||||
});
|
||||
|
||||
// Delete reset token
|
||||
await deleteResetToken(email);
|
||||
|
||||
// Send password changed confirmation email
|
||||
if (onPasswordChanged) {
|
||||
try {
|
||||
await sendPasswordChangedEmail(email);
|
||||
await onPasswordChanged(email);
|
||||
} catch (error) {
|
||||
// Log error but don't fail the password reset process
|
||||
fail(`Auth: failed to send password changed email to ${email}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify user email
|
||||
* @param {string} userId - User ID
|
||||
* @returns {Promise<Object>} Updated user
|
||||
*/
|
||||
async function verifyUserEmail(userId) {
|
||||
return await updateById('zen_auth_users', userId, {
|
||||
email_verified: true,
|
||||
@@ -270,12 +213,6 @@ async function verifyUserEmail(userId) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update user profile
|
||||
* @param {string} userId - User ID
|
||||
* @param {Object} updateData - Data to update
|
||||
* @returns {Promise<Object>} Updated user
|
||||
*/
|
||||
async function updateUser(userId, updateData) {
|
||||
const allowedFields = ['name', 'image', 'language'];
|
||||
const filteredData = {};
|
||||
@@ -291,11 +228,68 @@ async function updateUser(userId, updateData) {
|
||||
return await updateById('zen_auth_users', userId, filteredData);
|
||||
}
|
||||
|
||||
export {
|
||||
register,
|
||||
login,
|
||||
requestPasswordReset,
|
||||
resetPassword,
|
||||
verifyUserEmail,
|
||||
updateUser
|
||||
};
|
||||
async function completeAccountSetup({ email, token, password }) {
|
||||
if (!email || !token || !password) {
|
||||
throw new Error('L\'e-mail, le jeton et le mot de passe sont requis');
|
||||
}
|
||||
|
||||
if (password.length < 8) {
|
||||
throw new Error('Le mot de passe doit contenir au moins 8 caractères');
|
||||
}
|
||||
|
||||
if (password.length > 128) {
|
||||
throw new Error('Le mot de passe doit contenir 128 caractères ou moins');
|
||||
}
|
||||
|
||||
const hasUppercase = /[A-Z]/.test(password);
|
||||
const hasLowercase = /[a-z]/.test(password);
|
||||
const hasNumber = /\d/.test(password);
|
||||
|
||||
if (!hasUppercase || !hasLowercase || !hasNumber) {
|
||||
throw new Error('Le mot de passe doit contenir au moins une majuscule, une minuscule et un chiffre');
|
||||
}
|
||||
|
||||
const tokenValid = await verifyAccountSetupToken(email, token);
|
||||
if (!tokenValid) {
|
||||
throw new Error('Lien d\'invitation invalide ou expiré');
|
||||
}
|
||||
|
||||
const user = await findOne('zen_auth_users', { email });
|
||||
if (!user) {
|
||||
throw new Error('Lien d\'invitation invalide');
|
||||
}
|
||||
|
||||
const hashedPassword = await hashPassword(password);
|
||||
|
||||
const existingAccount = await findOne('zen_auth_accounts', {
|
||||
user_id: user.id,
|
||||
provider_id: 'credential'
|
||||
});
|
||||
|
||||
if (existingAccount) {
|
||||
await updateById('zen_auth_accounts', existingAccount.id, {
|
||||
password: hashedPassword,
|
||||
updated_at: new Date()
|
||||
});
|
||||
} else {
|
||||
await create('zen_auth_accounts', {
|
||||
id: generateId(),
|
||||
account_id: email,
|
||||
provider_id: 'credential',
|
||||
user_id: user.id,
|
||||
password: hashedPassword,
|
||||
updated_at: new Date()
|
||||
});
|
||||
}
|
||||
|
||||
await updateById('zen_auth_users', user.id, {
|
||||
email_verified: true,
|
||||
updated_at: new Date()
|
||||
});
|
||||
|
||||
await deleteAccountSetupToken(email);
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export { register, login, requestPasswordReset, resetPassword, verifyUserEmail, updateUser, completeAccountSetup };
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Static permission definitions — single source of truth.
|
||||
* No server-side imports: safe to use in both client and server code.
|
||||
*/
|
||||
|
||||
export const PERMISSIONS = {
|
||||
ADMIN_ACCESS: 'admin.access',
|
||||
USERS_VIEW: 'users.view',
|
||||
USERS_MANAGE: 'users.manage',
|
||||
ROLES_VIEW: 'roles.view',
|
||||
ROLES_MANAGE: 'roles.manage',
|
||||
};
|
||||
|
||||
export const PERMISSION_DEFINITIONS = [
|
||||
{ key: 'admin.access', name: 'Accès au panneau admin', description: "Permet d'accéder à l'interface d'administration.", group_name: 'Administration' },
|
||||
{ key: 'users.view', name: 'Voir les utilisateurs', description: 'Permet de consulter la liste des membres et leurs profils.', group_name: 'Utilisateurs' },
|
||||
{ key: 'users.manage', name: 'Gérer les utilisateurs', description: 'Permet de créer, modifier et supprimer des comptes membres.', group_name: 'Utilisateurs' },
|
||||
{ key: 'roles.view', name: 'Voir les rôles', description: 'Permet de consulter la liste des rôles et leurs permissions.', group_name: 'Rôles' },
|
||||
{ key: 'roles.manage', name: 'Gérer les rôles', description: 'Permet de créer, modifier et supprimer des rôles.', group_name: 'Rôles' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Returns permissions grouped by group_name.
|
||||
* @returns {Object.<string, Array>}
|
||||
*/
|
||||
export function getPermissionGroups() {
|
||||
return PERMISSION_DEFINITIONS.reduce((acc, perm) => {
|
||||
if (!acc[perm.group_name]) acc[perm.group_name] = [];
|
||||
acc[perm.group_name].push(perm);
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import { query, tableExists } from '@zen/core/database';
|
||||
import { generateId } from './password.js';
|
||||
import { done, warn } from '@zen/core/shared/logger';
|
||||
import { PERMISSION_DEFINITIONS } from './constants.js';
|
||||
import { registerPermissions, getRegisteredPermissions } from './permissions-registry.js';
|
||||
|
||||
const USER_ROLE_PERMISSIONS = [];
|
||||
|
||||
const ROLE_TABLES = [
|
||||
{
|
||||
name: 'zen_auth_roles',
|
||||
sql: `
|
||||
CREATE TABLE zen_auth_roles (
|
||||
id text NOT NULL PRIMARY KEY,
|
||||
name text NOT NULL UNIQUE,
|
||||
description text,
|
||||
color text DEFAULT '#6b7280',
|
||||
is_system boolean NOT NULL DEFAULT false,
|
||||
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`
|
||||
},
|
||||
{
|
||||
name: 'zen_auth_permissions',
|
||||
sql: `
|
||||
CREATE TABLE zen_auth_permissions (
|
||||
key text NOT NULL PRIMARY KEY,
|
||||
name text NOT NULL,
|
||||
description text,
|
||||
group_name text NOT NULL
|
||||
)
|
||||
`
|
||||
},
|
||||
{
|
||||
name: 'zen_auth_role_permissions',
|
||||
sql: `
|
||||
CREATE TABLE zen_auth_role_permissions (
|
||||
role_id text NOT NULL REFERENCES zen_auth_roles(id) ON DELETE CASCADE,
|
||||
permission_key text NOT NULL REFERENCES zen_auth_permissions(key) ON DELETE CASCADE,
|
||||
PRIMARY KEY (role_id, permission_key)
|
||||
)
|
||||
`
|
||||
},
|
||||
{
|
||||
name: 'zen_auth_user_roles',
|
||||
sql: `
|
||||
CREATE TABLE zen_auth_user_roles (
|
||||
user_id text NOT NULL REFERENCES zen_auth_users(id) ON DELETE CASCADE,
|
||||
role_id text NOT NULL REFERENCES zen_auth_roles(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (user_id, role_id)
|
||||
)
|
||||
`
|
||||
}
|
||||
];
|
||||
|
||||
async function dropRoleCheckConstraint() {
|
||||
await query(`
|
||||
DO $$ DECLARE cname text;
|
||||
BEGIN
|
||||
SELECT conname INTO cname FROM pg_constraint
|
||||
WHERE conrelid = 'zen_auth_users'::regclass AND contype = 'c' AND conname LIKE '%role%';
|
||||
IF cname IS NOT NULL THEN
|
||||
EXECUTE 'ALTER TABLE zen_auth_users DROP CONSTRAINT ' || quote_ident(cname);
|
||||
END IF;
|
||||
END$$;
|
||||
`);
|
||||
}
|
||||
|
||||
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() {
|
||||
// S'assure que les permissions core sont dans le registre, puis seed depuis
|
||||
// le registre — qui contient core + permissions enregistrées par les modules.
|
||||
registerPermissions(PERMISSION_DEFINITIONS);
|
||||
const allPermissions = getRegisteredPermissions();
|
||||
|
||||
for (const perm of allPermissions) {
|
||||
await query(
|
||||
`INSERT INTO zen_auth_permissions (key, name, description, group_name)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (key) DO UPDATE SET name = EXCLUDED.name, description = EXCLUDED.description, group_name = EXCLUDED.group_name`,
|
||||
[perm.key, perm.name, perm.description, perm.group_name]
|
||||
);
|
||||
}
|
||||
|
||||
await migratePermissions();
|
||||
|
||||
// Admin role
|
||||
const adminRoleId = generateId();
|
||||
await query(
|
||||
`INSERT INTO zen_auth_roles (id, name, description, color, is_system) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (name) DO NOTHING`,
|
||||
[adminRoleId, 'admin', 'Accès complet à toutes les fonctionnalités', '#ef4444', true]
|
||||
);
|
||||
const adminRole = await query(`SELECT id FROM zen_auth_roles WHERE name = 'admin'`);
|
||||
const adminId = adminRole.rows[0].id;
|
||||
|
||||
// Toute permission présente dans le catalogue est attribuée au rôle admin —
|
||||
// y compris les permissions ajoutées par les modules après le premier init.
|
||||
await query(
|
||||
`INSERT INTO zen_auth_role_permissions (role_id, permission_key)
|
||||
SELECT $1, key FROM zen_auth_permissions
|
||||
ON CONFLICT DO NOTHING`,
|
||||
[adminId]
|
||||
);
|
||||
|
||||
// User role
|
||||
const userRoleId = generateId();
|
||||
await query(
|
||||
`INSERT INTO zen_auth_roles (id, name, description, color, is_system) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (name) DO NOTHING`,
|
||||
[userRoleId, 'user', 'Accès de base en lecture', '#6b7280', true]
|
||||
);
|
||||
const userRole = await query(`SELECT id FROM zen_auth_roles WHERE name = 'user'`);
|
||||
const userId = userRole.rows[0].id;
|
||||
|
||||
for (const permKey of USER_ROLE_PERMISSIONS) {
|
||||
await query(
|
||||
`INSERT INTO zen_auth_role_permissions (role_id, permission_key) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
|
||||
[userId, permKey]
|
||||
);
|
||||
}
|
||||
|
||||
// Assign admin role to the first user if no user-roles exist yet
|
||||
const userRolesCount = await query(`SELECT COUNT(*) FROM zen_auth_user_roles`);
|
||||
if (parseInt(userRolesCount.rows[0].count) === 0) {
|
||||
const firstUser = await query(`SELECT id FROM zen_auth_users ORDER BY created_at ASC LIMIT 1`);
|
||||
if (firstUser.rows.length > 0) {
|
||||
await query(
|
||||
`INSERT INTO zen_auth_user_roles (user_id, role_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
|
||||
[firstUser.rows[0].id, adminId]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function createTables() {
|
||||
const created = [];
|
||||
const skipped = [];
|
||||
|
||||
// Drop legacy CHECK constraint on zen_auth_users.role if it exists
|
||||
await dropRoleCheckConstraint();
|
||||
|
||||
for (const table of ROLE_TABLES) {
|
||||
const exists = await tableExists(table.name);
|
||||
if (!exists) {
|
||||
await query(table.sql);
|
||||
created.push(table.name);
|
||||
done(`Created table: ${table.name}`);
|
||||
} else {
|
||||
skipped.push(table.name);
|
||||
}
|
||||
}
|
||||
|
||||
await seedDefaultRolesAndPermissions();
|
||||
|
||||
return { created, skipped };
|
||||
}
|
||||
|
||||
export async function dropTables() {
|
||||
const dropOrder = [...ROLE_TABLES].reverse().map(t => t.name);
|
||||
warn('Dropping all Zen role/permission tables...');
|
||||
for (const tableName of dropOrder) {
|
||||
const exists = await tableExists(tableName);
|
||||
if (exists) {
|
||||
await query(`DROP TABLE IF EXISTS "${tableName}" CASCADE`);
|
||||
done(`Dropped table: ${tableName}`);
|
||||
}
|
||||
}
|
||||
done('All role/permission tables dropped');
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import crypto from 'crypto';
|
||||
import { query, create, deleteWhere, updateById } from '@zen/core/database';
|
||||
import { generateToken, generateId } from './password.js';
|
||||
|
||||
export async function createEmailChangeToken(userId, newEmail) {
|
||||
const token = generateToken(32);
|
||||
const expiresAt = new Date();
|
||||
expiresAt.setHours(expiresAt.getHours() + 24);
|
||||
|
||||
await deleteWhere('zen_auth_verifications', { identifier: 'email_change:' + userId });
|
||||
|
||||
await create('zen_auth_verifications', {
|
||||
id: generateId(),
|
||||
identifier: 'email_change:' + userId,
|
||||
value: newEmail,
|
||||
token,
|
||||
expires_at: expiresAt,
|
||||
updated_at: new Date(),
|
||||
});
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
export async function verifyEmailChangeToken(token) {
|
||||
const result = await query(
|
||||
"SELECT * FROM zen_auth_verifications WHERE identifier LIKE 'email_change:%' AND token = $1",
|
||||
[token]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) return null;
|
||||
|
||||
const record = result.rows[0];
|
||||
|
||||
// Timing-safe comparison — same-length buffer padding prevents length-based timing leaks.
|
||||
const storedBuf = Buffer.from(record.token, 'utf8');
|
||||
const providedBuf = Buffer.from(
|
||||
token.length === record.token.length ? token : record.token,
|
||||
'utf8'
|
||||
);
|
||||
const tokensMatch = crypto.timingSafeEqual(storedBuf, providedBuf) && token.length === record.token.length;
|
||||
if (!tokensMatch) return null;
|
||||
|
||||
if (new Date(record.expires_at) < new Date()) {
|
||||
await deleteWhere('zen_auth_verifications', { id: record.id });
|
||||
return null;
|
||||
}
|
||||
|
||||
const userId = record.identifier.slice('email_change:'.length);
|
||||
const newEmail = record.value;
|
||||
|
||||
await deleteWhere('zen_auth_verifications', { id: record.id });
|
||||
|
||||
return { userId, newEmail };
|
||||
}
|
||||
|
||||
export async function applyEmailChange(userId, newEmail) {
|
||||
await updateById('zen_auth_users', userId, { email: newEmail, updated_at: new Date() });
|
||||
await query(
|
||||
'UPDATE zen_auth_accounts SET account_id = $1, updated_at = $2 WHERE user_id = $3 AND provider_id = $4',
|
||||
[newEmail, new Date(), userId, 'credential']
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
export { getUserById, getUserByEmail, countUsers, listUsers, updateUserById } from './queries.js';
|
||||
export { register, login, requestPasswordReset, resetPassword, verifyUserEmail, updateUser } from './auth.js';
|
||||
export { createSession, validateSession, deleteSession, deleteUserSessions, refreshSession } from './session.js';
|
||||
export { createEmailVerification, verifyEmailToken, createPasswordReset, verifyResetToken, deleteResetToken } from './verifications.js';
|
||||
export { hashPassword, verifyPassword, generateToken, generateId } from './password.js';
|
||||
export { listRoles, getRoleById, createRole, updateRole, deleteRole, getUserRoles, assignUserRole, revokeUserRole } from './roles.js';
|
||||
export {
|
||||
PERMISSIONS,
|
||||
PERMISSION_DEFINITIONS,
|
||||
getPermissionGroups,
|
||||
hasPermission,
|
||||
getUserPermissions,
|
||||
registerPermission,
|
||||
registerPermissions,
|
||||
getRegisteredPermissions,
|
||||
getRegisteredPermissionKeys,
|
||||
} from './permissions.js';
|
||||
@@ -1,21 +1,8 @@
|
||||
/**
|
||||
* Password Hashing and Verification
|
||||
* Provides secure password hashing using bcrypt
|
||||
*/
|
||||
|
||||
import crypto from 'crypto';
|
||||
|
||||
/**
|
||||
* Hash a password using scrypt (Node.js native)
|
||||
* @param {string} password - Plain text password
|
||||
* @returns {Promise<string>} Hashed password
|
||||
*/
|
||||
async function hashPassword(password) {
|
||||
return new Promise((resolve, reject) => {
|
||||
// Generate a salt
|
||||
const salt = crypto.randomBytes(16).toString('hex');
|
||||
|
||||
// Hash password with salt using scrypt
|
||||
crypto.scrypt(password, salt, 64, (err, derivedKey) => {
|
||||
if (err) reject(err);
|
||||
resolve(salt + ':' + derivedKey.toString('hex'));
|
||||
@@ -23,22 +10,13 @@ async function hashPassword(password) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a password against a hash
|
||||
* @param {string} password - Plain text password
|
||||
* @param {string} hash - Hashed password
|
||||
* @returns {Promise<boolean>} True if password matches, false otherwise
|
||||
*/
|
||||
async function verifyPassword(password, hash) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const [salt, key] = hash.split(':');
|
||||
|
||||
crypto.scrypt(password, salt, 64, (err, derivedKey) => {
|
||||
if (err) { reject(err); return; }
|
||||
try {
|
||||
const storedKey = Buffer.from(key, 'hex');
|
||||
// timingSafeEqual requires identical lengths; if the stored hash is
|
||||
// malformed the lengths will differ and we reject without leaking timing.
|
||||
if (storedKey.length !== derivedKey.length) { resolve(false); return; }
|
||||
resolve(crypto.timingSafeEqual(storedKey, derivedKey));
|
||||
} catch {
|
||||
@@ -48,26 +26,12 @@ async function verifyPassword(password, hash) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a random token
|
||||
* @param {number} length - Token length in bytes (default: 32)
|
||||
* @returns {string} Random token
|
||||
*/
|
||||
function generateToken(length = 32) {
|
||||
return crypto.randomBytes(length).toString('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a random ID
|
||||
* @returns {string} Random ID
|
||||
*/
|
||||
function generateId() {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
export {
|
||||
hashPassword,
|
||||
verifyPassword,
|
||||
generateToken,
|
||||
generateId
|
||||
};
|
||||
export { hashPassword, verifyPassword, generateToken, generateId };
|
||||
@@ -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();
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { query } from '@zen/core/database';
|
||||
export { PERMISSIONS, PERMISSION_DEFINITIONS, getPermissionGroups } from './constants.js';
|
||||
export {
|
||||
registerPermission,
|
||||
registerPermissions,
|
||||
getRegisteredPermissions,
|
||||
getRegisteredPermissionKeys,
|
||||
} from './permissions-registry.js';
|
||||
|
||||
export async function hasPermission(userId, permissionKey) {
|
||||
const result = await query(
|
||||
`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
|
||||
LIMIT 1`,
|
||||
[userId, permissionKey]
|
||||
);
|
||||
return result.rows.length > 0;
|
||||
}
|
||||
|
||||
export async function getUserPermissions(userId) {
|
||||
const result = await query(
|
||||
`SELECT DISTINCT rp.permission_key
|
||||
FROM zen_auth_user_roles ur
|
||||
JOIN zen_auth_role_permissions rp ON rp.role_id = ur.role_id
|
||||
WHERE ur.user_id = $1`,
|
||||
[userId]
|
||||
);
|
||||
return result.rows.map(r => r.permission_key);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { query, findOne, updateById, count } from '@zen/core/database';
|
||||
|
||||
const MAX_PAGE_LIMIT = 100;
|
||||
const ALLOWED_SORT_COLUMNS = ['id', 'email', 'name', 'role', 'email_verified', 'created_at'];
|
||||
|
||||
async function getUserById(id) {
|
||||
return findOne('zen_auth_users', { id });
|
||||
}
|
||||
|
||||
async function getUserByEmail(email) {
|
||||
return findOne('zen_auth_users', { email });
|
||||
}
|
||||
|
||||
async function countUsers() {
|
||||
return count('zen_auth_users');
|
||||
}
|
||||
|
||||
async function listUsers({ page = 1, limit = 10, sortBy = 'created_at', sortOrder = 'desc' } = {}) {
|
||||
const safePage = Math.max(1, page);
|
||||
const safeLimit = Math.min(Math.max(1, limit), MAX_PAGE_LIMIT);
|
||||
const offset = (safePage - 1) * safeLimit;
|
||||
const sortColumn = ALLOWED_SORT_COLUMNS.includes(sortBy) ? sortBy : 'created_at';
|
||||
const order = sortOrder.toLowerCase() === 'asc' ? 'ASC' : 'DESC';
|
||||
|
||||
const result = await query(
|
||||
`SELECT id, email, name, role, image, email_verified, created_at FROM zen_auth_users ORDER BY "${sortColumn}" ${order} LIMIT $1 OFFSET $2`,
|
||||
[safeLimit, offset]
|
||||
);
|
||||
const total = await countUsers();
|
||||
|
||||
return {
|
||||
users: result.rows,
|
||||
pagination: { page: safePage, limit: safeLimit, total, totalPages: Math.ceil(total / safeLimit) }
|
||||
};
|
||||
}
|
||||
|
||||
async function updateUserById(id, fields) {
|
||||
const allowed = ['name', 'role', 'email_verified', 'image', 'language', 'updated_at'];
|
||||
const data = { updated_at: new Date() };
|
||||
for (const key of allowed) {
|
||||
if (fields[key] !== undefined) data[key] = fields[key];
|
||||
}
|
||||
return updateById('zen_auth_users', id, data);
|
||||
}
|
||||
|
||||
export { getUserById, getUserByEmail, countUsers, listUsers, updateUserById };
|
||||
@@ -0,0 +1,138 @@
|
||||
import { query, transaction } from '@zen/core/database';
|
||||
import { generateId } from './password.js';
|
||||
import { getRegisteredPermissionKeys } from './permissions-registry.js';
|
||||
|
||||
export async function listRoles() {
|
||||
const result = await query(
|
||||
`SELECT r.*,
|
||||
COUNT(DISTINCT rp.permission_key)::int AS permission_count,
|
||||
COUNT(DISTINCT ur.user_id)::int AS user_count
|
||||
FROM zen_auth_roles r
|
||||
LEFT JOIN zen_auth_role_permissions rp ON rp.role_id = r.id
|
||||
LEFT JOIN zen_auth_user_roles ur ON ur.role_id = r.id
|
||||
GROUP BY r.id
|
||||
ORDER BY r.created_at ASC`
|
||||
);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
export async function getRoleById(id) {
|
||||
const roleResult = await query(
|
||||
`SELECT * FROM zen_auth_roles WHERE id = $1`,
|
||||
[id]
|
||||
);
|
||||
if (roleResult.rows.length === 0) return null;
|
||||
|
||||
const permsResult = await query(
|
||||
`SELECT permission_key FROM zen_auth_role_permissions WHERE role_id = $1`,
|
||||
[id]
|
||||
);
|
||||
|
||||
return {
|
||||
...roleResult.rows[0],
|
||||
permission_keys: permsResult.rows.map(r => r.permission_key)
|
||||
};
|
||||
}
|
||||
|
||||
export async function createRole({ name, description = null, color = '#6b7280' }) {
|
||||
if (!name || !name.trim()) throw new Error('Role name is required');
|
||||
|
||||
const id = generateId();
|
||||
const result = await query(
|
||||
`INSERT INTO zen_auth_roles (id, name, description, color, is_system)
|
||||
VALUES ($1, $2, $3, $4, false)
|
||||
RETURNING *`,
|
||||
[id, name.trim(), description || null, color]
|
||||
);
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
export async function updateRole(roleId, { name, description, color, permissionKeys }) {
|
||||
const role = await query(`SELECT is_system FROM zen_auth_roles WHERE id = $1`, [roleId]);
|
||||
if (role.rows.length === 0) throw new Error('Role not found');
|
||||
|
||||
const isSystem = role.rows[0].is_system;
|
||||
|
||||
return transaction(async (client) => {
|
||||
const updateFields = [];
|
||||
const values = [];
|
||||
let idx = 1;
|
||||
|
||||
if (name !== undefined) {
|
||||
if (!name.trim()) throw new Error('Role name cannot be empty');
|
||||
updateFields.push(`name = $${idx++}`);
|
||||
values.push(name.trim());
|
||||
}
|
||||
if (description !== undefined) {
|
||||
updateFields.push(`description = $${idx++}`);
|
||||
values.push(description || null);
|
||||
}
|
||||
if (color !== undefined) {
|
||||
updateFields.push(`color = $${idx++}`);
|
||||
values.push(color);
|
||||
}
|
||||
updateFields.push(`updated_at = $${idx++}`);
|
||||
values.push(new Date());
|
||||
values.push(roleId);
|
||||
|
||||
const updated = await client.query(
|
||||
`UPDATE zen_auth_roles SET ${updateFields.join(', ')} WHERE id = $${idx} RETURNING *`,
|
||||
values
|
||||
);
|
||||
|
||||
if (!isSystem && permissionKeys !== undefined) {
|
||||
const validKeys = getRegisteredPermissionKeys();
|
||||
const safeKeys = [...new Set(permissionKeys)].filter(k => validKeys.has(k));
|
||||
await client.query(`DELETE FROM zen_auth_role_permissions WHERE role_id = $1`, [roleId]);
|
||||
for (const key of safeKeys) {
|
||||
await client.query(
|
||||
`INSERT INTO zen_auth_role_permissions (role_id, permission_key) VALUES ($1, $2)`,
|
||||
[roleId, key]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const perms = await client.query(
|
||||
`SELECT permission_key FROM zen_auth_role_permissions WHERE role_id = $1`,
|
||||
[roleId]
|
||||
);
|
||||
|
||||
return {
|
||||
...updated.rows[0],
|
||||
permission_keys: perms.rows.map(r => r.permission_key)
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteRole(roleId) {
|
||||
const result = await query(`SELECT is_system FROM zen_auth_roles WHERE id = $1`, [roleId]);
|
||||
if (result.rows.length === 0) throw new Error('Role not found');
|
||||
if (result.rows[0].is_system) throw new Error('Cannot delete a system role');
|
||||
|
||||
await query(`DELETE FROM zen_auth_roles WHERE id = $1`, [roleId]);
|
||||
}
|
||||
|
||||
export async function getUserRoles(userId) {
|
||||
const result = await query(
|
||||
`SELECT r.* FROM zen_auth_roles r
|
||||
JOIN zen_auth_user_roles ur ON ur.role_id = r.id
|
||||
WHERE ur.user_id = $1
|
||||
ORDER BY r.created_at ASC`,
|
||||
[userId]
|
||||
);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
export async function assignUserRole(userId, roleId) {
|
||||
await query(
|
||||
`INSERT INTO zen_auth_user_roles (user_id, role_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
|
||||
[userId, roleId]
|
||||
);
|
||||
}
|
||||
|
||||
export async function revokeUserRole(userId, roleId) {
|
||||
await query(
|
||||
`DELETE FROM zen_auth_user_roles WHERE user_id = $1 AND role_id = $2`,
|
||||
[userId, roleId]
|
||||
);
|
||||
}
|
||||
@@ -1,25 +1,10 @@
|
||||
/**
|
||||
* Session Management
|
||||
* Handles user session creation, validation, and deletion
|
||||
*/
|
||||
|
||||
import { create, findOne, deleteWhere, updateById } from '@zen/core/database';
|
||||
import { generateToken, generateId } from './password.js';
|
||||
|
||||
/**
|
||||
* Create a new session for a user
|
||||
* @param {string} userId - User ID
|
||||
* @param {Object} options - Session options (ipAddress, userAgent)
|
||||
* @returns {Promise<Object>} Session object with token
|
||||
*/
|
||||
async function createSession(userId, options = {}) {
|
||||
const { ipAddress, userAgent } = options;
|
||||
|
||||
// Generate session token
|
||||
const token = generateToken(32);
|
||||
const sessionId = generateId();
|
||||
|
||||
// Session expires in 30 days
|
||||
const expiresAt = new Date();
|
||||
expiresAt.setDate(expiresAt.getDate() + 30);
|
||||
|
||||
@@ -36,103 +21,59 @@ async function createSession(userId, options = {}) {
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a session token
|
||||
* @param {string} token - Session token
|
||||
* @returns {Promise<Object|null>} Session object with user data or null if invalid
|
||||
*/
|
||||
async function validateSession(token) {
|
||||
if (!token) return null;
|
||||
|
||||
const session = await findOne('zen_auth_sessions', { token });
|
||||
|
||||
if (!session) return null;
|
||||
|
||||
// Check if session is expired
|
||||
if (new Date(session.expires_at) < new Date()) {
|
||||
await deleteSession(token);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get user data
|
||||
const user = await findOne('zen_auth_users', { id: session.user_id });
|
||||
|
||||
if (!user) {
|
||||
await deleteSession(token);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Auto-refresh session if it expires in less than 20 days
|
||||
const now = new Date();
|
||||
const expiresAt = new Date(session.expires_at);
|
||||
const daysUntilExpiry = Math.ceil((expiresAt - now) / (1000 * 60 * 60 * 24));
|
||||
|
||||
let sessionRefreshed = false;
|
||||
|
||||
if (daysUntilExpiry < 20) {
|
||||
// Extend session to 30 days from now
|
||||
const newExpiresAt = new Date();
|
||||
newExpiresAt.setDate(newExpiresAt.getDate() + 30);
|
||||
|
||||
await updateById('zen_auth_sessions', session.id, {
|
||||
expires_at: newExpiresAt,
|
||||
updated_at: new Date()
|
||||
});
|
||||
|
||||
// Update the session object with new expiration
|
||||
session.expires_at = newExpiresAt;
|
||||
sessionRefreshed = true;
|
||||
}
|
||||
|
||||
return {
|
||||
session,
|
||||
user,
|
||||
sessionRefreshed
|
||||
};
|
||||
return { session, user, sessionRefreshed };
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a session
|
||||
* @param {string} token - Session token
|
||||
* @returns {Promise<number>} Number of deleted sessions
|
||||
*/
|
||||
async function deleteSession(token) {
|
||||
return await deleteWhere('zen_auth_sessions', { token });
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all sessions for a user
|
||||
* @param {string} userId - User ID
|
||||
* @returns {Promise<number>} Number of deleted sessions
|
||||
*/
|
||||
async function deleteUserSessions(userId) {
|
||||
return await deleteWhere('zen_auth_sessions', { user_id: userId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh a session (extend expiration)
|
||||
* @param {string} token - Session token
|
||||
* @returns {Promise<Object|null>} Updated session or null
|
||||
*/
|
||||
async function refreshSession(token) {
|
||||
const session = await findOne('zen_auth_sessions', { token });
|
||||
|
||||
if (!session) return null;
|
||||
|
||||
// Extend session by 30 days
|
||||
const expiresAt = new Date();
|
||||
expiresAt.setDate(expiresAt.getDate() + 30);
|
||||
|
||||
return await updateById('zen_auth_sessions', session.id, {
|
||||
expires_at: expiresAt,
|
||||
updated_at: new Date()
|
||||
});
|
||||
}
|
||||
|
||||
export {
|
||||
createSession,
|
||||
validateSession,
|
||||
deleteSession,
|
||||
deleteUserSessions,
|
||||
refreshSession
|
||||
};
|
||||
export { createSession, validateSession, deleteSession, deleteUserSessions, refreshSession };
|
||||
@@ -1,12 +1,6 @@
|
||||
import crypto from 'crypto';
|
||||
import { render } from '@react-email/components';
|
||||
import { create, findOne, deleteWhere } from '@zen/core/database';
|
||||
import { generateToken, generateId } from './password.js';
|
||||
import { fail, info } from '@zen/core/shared/logger';
|
||||
import { sendEmail } from '@zen/core/email';
|
||||
import { VerificationEmail } from '../templates/VerificationEmail.jsx';
|
||||
import { PasswordResetEmail } from '../templates/PasswordResetEmail.jsx';
|
||||
import { PasswordChangedEmail } from '../templates/PasswordChangedEmail.jsx';
|
||||
|
||||
async function createEmailVerification(email) {
|
||||
const token = generateToken(32);
|
||||
@@ -104,51 +98,52 @@ function deleteResetToken(email) {
|
||||
return deleteWhere('zen_auth_verifications', { identifier: 'password_reset', value: email });
|
||||
}
|
||||
|
||||
async function sendVerificationEmail(email, token, baseUrl) {
|
||||
const appName = process.env.ZEN_NAME || 'ZEN';
|
||||
const verificationUrl = `${baseUrl}/auth/confirm?email=${encodeURIComponent(email)}&token=${token}`;
|
||||
const html = await render(<VerificationEmail verificationUrl={verificationUrl} companyName={appName} />);
|
||||
const result = await sendEmail({ to: email, subject: `Confirmez votre adresse courriel – ${appName}`, html });
|
||||
if (!result.success) {
|
||||
fail(`Auth: failed to send verification email to ${email}: ${result.error}`);
|
||||
throw new Error('Failed to send verification email');
|
||||
}
|
||||
info(`Auth: verification email sent to ${email}`);
|
||||
return result;
|
||||
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 sendPasswordResetEmail(email, token, baseUrl) {
|
||||
const appName = process.env.ZEN_NAME || 'ZEN';
|
||||
const resetUrl = `${baseUrl}/auth/reset?email=${encodeURIComponent(email)}&token=${token}`;
|
||||
const html = await render(<PasswordResetEmail resetUrl={resetUrl} companyName={appName} />);
|
||||
const result = await sendEmail({ to: email, subject: `Réinitialisation du mot de passe – ${appName}`, html });
|
||||
if (!result.success) {
|
||||
fail(`Auth: failed to send password reset email to ${email}: ${result.error}`);
|
||||
throw new Error('Failed to send password reset email');
|
||||
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;
|
||||
}
|
||||
info(`Auth: password reset email sent to ${email}`);
|
||||
return result;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async function sendPasswordChangedEmail(email) {
|
||||
const appName = process.env.ZEN_NAME || 'ZEN';
|
||||
const html = await render(<PasswordChangedEmail email={email} companyName={appName} />);
|
||||
const result = await sendEmail({ to: email, subject: `Mot de passe modifié – ${appName}`, html });
|
||||
if (!result.success) {
|
||||
fail(`Auth: failed to send password changed email to ${email}: ${result.error}`);
|
||||
throw new Error('Failed to send password changed email');
|
||||
}
|
||||
info(`Auth: password changed email sent to ${email}`);
|
||||
return result;
|
||||
function deleteAccountSetupToken(email) {
|
||||
return deleteWhere('zen_auth_verifications', { identifier: 'account_setup', value: email });
|
||||
}
|
||||
|
||||
export {
|
||||
createEmailVerification,
|
||||
verifyEmailToken,
|
||||
createPasswordReset,
|
||||
verifyResetToken,
|
||||
deleteResetToken,
|
||||
sendVerificationEmail,
|
||||
sendPasswordResetEmail,
|
||||
sendPasswordChangedEmail
|
||||
};
|
||||
export { createEmailVerification, verifyEmailToken, createPasswordReset, verifyResetToken, deleteResetToken, createAccountSetup, verifyAccountSetupToken, deleteAccountSetupToken };
|
||||
@@ -0,0 +1,27 @@
|
||||
import AdminShell from './components/AdminShell.js';
|
||||
import { protectAdmin } from './protect.js';
|
||||
import { buildNavigationSections, buildBottomNavItems } from './navigation.js';
|
||||
import { logoutAction } from '@zen/core/features/auth/actions';
|
||||
import { getAppName } from '@zen/core';
|
||||
import { getUserPermissions } from '@zen/core/users';
|
||||
import './widgets/index.server.js';
|
||||
|
||||
export default async function AdminLayout({ children }) {
|
||||
const session = await protectAdmin();
|
||||
const appName = getAppName();
|
||||
const permissions = await getUserPermissions(session.user.id);
|
||||
const navigationSections = buildNavigationSections('/', permissions);
|
||||
const bottomNavItems = buildBottomNavItems('/');
|
||||
|
||||
return (
|
||||
<AdminShell
|
||||
user={session.user}
|
||||
onLogout={logoutAction}
|
||||
appName={appName}
|
||||
navigationSections={navigationSections}
|
||||
bottomNavItems={bottomNavItems}
|
||||
>
|
||||
{children}
|
||||
</AdminShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
'use client';
|
||||
|
||||
import { getPage } from './registry.js';
|
||||
import './pages/DashboardPage.client.js';
|
||||
import './pages/UsersPage.client.js';
|
||||
import './pages/RolesPage.client.js';
|
||||
import './pages/ProfilePage.client.js';
|
||||
import './pages/SettingsPage.client.js';
|
||||
import './pages/ConfirmEmailChangePage.client.js';
|
||||
import './widgets/index.client.js';
|
||||
import './devkit/DevkitPage.client.js';
|
||||
import '../media/pages/MediaPage.client.js';
|
||||
|
||||
export default function AdminPageClient({ params, user, widgetData, appConfig, devkitEnabled }) {
|
||||
const parts = params?.admin || [];
|
||||
const [first] = parts;
|
||||
|
||||
const slug = first || 'dashboard';
|
||||
const page = getPage(slug) || getPage('dashboard');
|
||||
|
||||
if (!page) return null;
|
||||
|
||||
const { Component } = page;
|
||||
if (slug === 'dashboard') {
|
||||
return <Component user={user} stats={widgetData} />;
|
||||
}
|
||||
if (slug === 'settings') {
|
||||
return <Component user={user} appConfig={appConfig} />;
|
||||
}
|
||||
if (slug === 'devkit') {
|
||||
return <Component user={user} params={parts} devkitEnabled={devkitEnabled} />;
|
||||
}
|
||||
return <Component user={user} params={parts} />;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import AdminPageClient from './AdminPage.client.js';
|
||||
import { protectAdmin } from './protect.js';
|
||||
import { collectWidgetData } from './registry.js';
|
||||
import { getAppConfig, getPublicBaseUrl } from '@zen/core';
|
||||
import { isDevkitEnabled } from '../../shared/lib/appConfig.js';
|
||||
import { getUserPermissions } from '@zen/core/users';
|
||||
|
||||
export default async function AdminPage({ params }) {
|
||||
const resolvedParams = await params;
|
||||
const session = await protectAdmin();
|
||||
const [widgetData, permissions] = await Promise.all([
|
||||
collectWidgetData(),
|
||||
getUserPermissions(session.user.id),
|
||||
]);
|
||||
const appConfig = { ...getAppConfig(), siteUrl: getPublicBaseUrl() };
|
||||
const devkitEnabled = isDevkitEnabled();
|
||||
const user = { ...session.user, permissions };
|
||||
|
||||
return (
|
||||
<AdminPageClient
|
||||
params={resolvedParams}
|
||||
user={user}
|
||||
widgetData={widgetData}
|
||||
appConfig={appConfig}
|
||||
devkitEnabled={devkitEnabled}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
# 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, getNavItemBasePath, findActiveNavContext
|
||||
├── 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, basePath?, 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 (cible du clic dans la sidebar) |
|
||||
| `basePath` | `string` | Préfixe de routes considérées « sous » cette entrée — utilisé pour souligner l'item actif et construire le fil d'Ariane sur les sous-routes (`/edit/:id`, `/new`...). Auto-déduit : si `href` finit par `/list`, on retire le `/list` ; sinon `basePath = href`. À spécifier explicitement uniquement quand l'auto-déduction ne suffit pas. |
|
||||
| `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. |
|
||||
|
||||
#### Item actif et fil d'Ariane
|
||||
|
||||
L'item « actif » dans la sidebar est celui dont le `basePath` matche le préfixe **le plus long** du pathname courant. Sur `/admin/posts/blogue/edit/1`, l'item « Posts » (basePath `/admin/posts/blogue`) est souligné ; sur `/admin/posts/blogue/taxonomies/categories`, c'est l'item « Catégories » qui gagne car son basePath est plus spécifique.
|
||||
|
||||
Le fil d'Ariane se construit automatiquement à partir de cet item :
|
||||
|
||||
- `[icon] > <section.title> > <item.label>` — si la section contient plusieurs items
|
||||
- `[icon] > <item.label>` — si la section ne contient que cet item et qu'ils portent le même nom (ex : section "Utilisateurs" + item "Utilisateurs")
|
||||
- Suivi de `> Nouveau` ou `> Modification` quand l'URL contient `/new` ou `/edit/:id` après le basePath.
|
||||
|
||||
Pour personnaliser les labels d'action, enregistrer une page avec un slug `<root>:new` ou `<root>:edit` :
|
||||
|
||||
```js
|
||||
registerPage({ slug: 'posts:edit', breadcrumbLabel: "Modifier l'article" });
|
||||
registerPage({ slug: 'posts:new', breadcrumbLabel: 'Nouvel article' });
|
||||
```
|
||||
|
||||
(`<root>` = premier segment d'URL après `/admin/`, ex : `posts` pour `/admin/posts/blogue/edit/1`.)
|
||||
|
||||
---
|
||||
|
||||
### 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
|
||||
```
|
||||
@@ -1,10 +0,0 @@
|
||||
/**
|
||||
* Admin Server Actions
|
||||
*
|
||||
* Exported separately from admin/index.js to avoid bundling
|
||||
* server-side code (which includes database imports) into client components.
|
||||
*
|
||||
* Usage: import { getDashboardStats } from '@zen/core/features/admin/actions';
|
||||
*/
|
||||
|
||||
export { getDashboardStats } from './actions/statsActions.js';
|
||||
@@ -1,72 +0,0 @@
|
||||
/**
|
||||
* Admin Stats Actions
|
||||
* Server-side actions for core dashboard statistics
|
||||
*
|
||||
* Usage in your Next.js app:
|
||||
*
|
||||
* ```javascript
|
||||
* // app/(admin)/admin/[...admin]/page.js
|
||||
* import { protectAdmin } from '@zen/core/features/admin';
|
||||
* import { getDashboardStats } from '@zen/core/features/admin/actions';
|
||||
* import { AdminPagesClient } from '@zen/core/features/admin/pages';
|
||||
*
|
||||
* export default async function AdminPage({ params }) {
|
||||
* const { user } = await protectAdmin();
|
||||
*
|
||||
* const statsResult = await getDashboardStats();
|
||||
* const dashboardStats = statsResult.success ? statsResult.stats : null;
|
||||
*
|
||||
* return (
|
||||
* <AdminPagesClient
|
||||
* params={params}
|
||||
* user={user}
|
||||
* dashboardStats={dashboardStats}
|
||||
* />
|
||||
* );
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
|
||||
'use server';
|
||||
|
||||
import { query } from '@zen/core/database';
|
||||
import { fail } from '@zen/core/shared/logger';
|
||||
|
||||
/**
|
||||
* Get total number of users
|
||||
* @returns {Promise<number>}
|
||||
*/
|
||||
async function getTotalUsersCount() {
|
||||
try {
|
||||
const result = await query(
|
||||
`SELECT COUNT(*) as count FROM zen_auth_users`
|
||||
);
|
||||
return parseInt(result.rows[0].count) || 0;
|
||||
} catch (error) {
|
||||
fail(`Error getting users count: ${error.message}`);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get core dashboard statistics
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export async function getDashboardStats() {
|
||||
try {
|
||||
const totalUsers = await getTotalUsersCount();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
stats: {
|
||||
totalUsers,
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
fail(`Error getting dashboard stats: ${error.message}`);
|
||||
return {
|
||||
success: false,
|
||||
error: error.message || 'Failed to get dashboard statistics'
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,213 +1,30 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Menu, Transition } from '@headlessui/react';
|
||||
import { Fragment } from 'react';
|
||||
import { ChevronDownIcon } from '@zen/core/shared/icons';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import ThemeToggle from './ThemeToggle';
|
||||
import { Button } from '@zen/core/shared/components';
|
||||
|
||||
const AdminHeader = ({ isMobileMenuOpen, setIsMobileMenuOpen, user, onLogout, appName = 'ZEN' }) => {
|
||||
const AdminHeader = ({ title, description, backHref, backLabel = '← Retour', action }) => {
|
||||
const router = useRouter();
|
||||
|
||||
const getImageUrl = (imageKey) => {
|
||||
if (!imageKey) return null;
|
||||
return `/zen/api/storage/${imageKey}`;
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
if (onLogout) {
|
||||
const result = await onLogout();
|
||||
if (result && result.success) {
|
||||
router.push('/auth/login');
|
||||
} else {
|
||||
console.error('Logout failed:', result?.error);
|
||||
router.push('/auth/login');
|
||||
}
|
||||
} else {
|
||||
router.push('/auth/login');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Logout error:', error);
|
||||
router.push('/auth/login');
|
||||
}
|
||||
};
|
||||
|
||||
const getUserInitials = (name) => {
|
||||
if (!name) return 'U';
|
||||
return name
|
||||
.split(' ')
|
||||
.map(n => n[0])
|
||||
.join('')
|
||||
.toUpperCase()
|
||||
.slice(0, 2);
|
||||
};
|
||||
|
||||
const quickLinks = [];
|
||||
|
||||
const userInitials = getUserInitials(user?.name);
|
||||
|
||||
return (
|
||||
<header className="bg-white dark:bg-black border-b border-neutral-200 dark:border-neutral-800/70 sticky top-0 z-30 h-14 flex items-center w-full">
|
||||
<div className="flex items-center justify-between lg:justify-end px-4 lg:px-6 py-2 w-full">
|
||||
{/* Left section - Mobile menu button + Logo (hidden on desktop) */}
|
||||
<div className="flex items-center space-x-3 lg:hidden">
|
||||
<button
|
||||
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
|
||||
className="p-2 rounded-lg bg-neutral-100 dark:bg-neutral-900/50 border border-neutral-200 dark:border-neutral-700/50 text-neutral-900 dark:text-white hover:bg-neutral-200 dark:hover:bg-neutral-800 transition-colors duration-200"
|
||||
aria-label="Toggle menu"
|
||||
>
|
||||
<svg className={`h-5 w-5 transition-transform duration-200 ${isMobileMenuOpen ? 'rotate-90' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={isMobileMenuOpen ? "M6 18L18 6M6 6l12 12" : "M4 6h16M4 12h16M4 18h16"} />
|
||||
</svg>
|
||||
</button>
|
||||
<h1 className="text-neutral-900 dark:text-white font-semibold text-lg">{appName}</h1>
|
||||
</div>
|
||||
|
||||
{/* Right Section - Theme Toggle + Quick Links + Profile */}
|
||||
<div className="flex items-center space-x-3 sm:space-x-4">
|
||||
{/* Quick Links - Hidden on very small screens */}
|
||||
<nav className="hidden sm:flex items-center space-x-4 lg:space-x-6">
|
||||
{quickLinks.map((link) => (
|
||||
<a
|
||||
key={link.name}
|
||||
href={link.href}
|
||||
className="text-sm text-neutral-600 dark:text-neutral-300 hover:text-neutral-900 dark:hover:text-white transition-colors duration-200"
|
||||
>
|
||||
{link.name}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Theme Toggle */}
|
||||
<ThemeToggle />
|
||||
|
||||
{/* User Profile Menu */}
|
||||
<Menu as="div" className="relative">
|
||||
<Menu.Button className="cursor-pointer flex items-center space-x-2 sm:space-x-3 px-2 sm:px-3 py-2 rounded-xl hover:bg-neutral-100 dark:hover:bg-neutral-800/50 transition-all duration-200 group ui-open:bg-neutral-100 dark:ui-open:bg-neutral-800/50 outline-none">
|
||||
{/* Avatar for desktop - hidden on mobile */}
|
||||
<div className="hidden sm:flex items-center space-x-3">
|
||||
{getImageUrl(user?.image) && (
|
||||
<img
|
||||
src={getImageUrl(user.image)}
|
||||
alt={user?.name || 'User'}
|
||||
className="w-8 h-8 rounded-lg object-cover"
|
||||
/>
|
||||
)}
|
||||
<div className="text-left">
|
||||
<p className="text-sm font-medium text-neutral-900 dark:text-white">{user?.name || 'User'}</p>
|
||||
</div>
|
||||
</div>
|
||||
{/* Avatar for mobile - visible on mobile only */}
|
||||
<div className="sm:hidden">
|
||||
{getImageUrl(user?.image) && (
|
||||
<img
|
||||
src={getImageUrl(user.image)}
|
||||
alt={user?.name || 'User'}
|
||||
className="w-8 h-8 rounded-lg object-cover"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<ChevronDownIcon className="h-4 w-4 text-neutral-400 transition-transform duration-200 ui-open:rotate-180" />
|
||||
</Menu.Button>
|
||||
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-200"
|
||||
enterFrom="opacity-0 -translate-y-2"
|
||||
enterTo="opacity-100 translate-y-0"
|
||||
leave="transition ease-in duration-150"
|
||||
leaveFrom="opacity-100 translate-y-0"
|
||||
leaveTo="opacity-0 -translate-y-2"
|
||||
>
|
||||
<Menu.Items className="absolute right-0 mt-2 w-56 sm:w-64 bg-white dark:bg-neutral-900/95 backdrop-blur-sm border border-neutral-200 dark:border-neutral-700/50 rounded-xl shadow-xl z-50 outline-none">
|
||||
<div className="p-4 border-b border-neutral-200 dark:border-neutral-700/50">
|
||||
<div className="flex items-center space-x-3">
|
||||
{getImageUrl(user?.image) && (
|
||||
<img
|
||||
src={getImageUrl(user.image)}
|
||||
alt={user?.name || 'User'}
|
||||
className="w-10 h-10 rounded-lg object-cover"
|
||||
/>
|
||||
)}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-neutral-900 dark:text-white">{user?.name || 'User'}</p>
|
||||
<p className="text-xs text-neutral-400">{user?.email || 'email@example.com'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Links for mobile */}
|
||||
{quickLinks.length > 0 && (
|
||||
<div className="sm:hidden py-2 border-b border-neutral-200 dark:border-neutral-700/50">
|
||||
{quickLinks.map((link) => (
|
||||
<Menu.Item key={link.name}>
|
||||
{({ active }) => (
|
||||
<a
|
||||
href={link.href}
|
||||
className={`flex items-center px-4 py-3 text-sm transition-all duration-200 ${
|
||||
active
|
||||
? 'bg-neutral-100 dark:bg-neutral-800/50 text-neutral-900 dark:text-white'
|
||||
: 'text-neutral-600 dark:text-neutral-300'
|
||||
}`}
|
||||
>
|
||||
<svg className="mr-3 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
{link.name}
|
||||
</a>
|
||||
<h1 className="text-lg sm:text-xl font-semibold text-neutral-900 dark:text-white">{title}</h1>
|
||||
{description && (
|
||||
<p className="mt-1 text-[13px] text-neutral-500 dark:text-neutral-400">{description}</p>
|
||||
)}
|
||||
</Menu.Item>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex gap-2'>
|
||||
{backHref && (
|
||||
<Button variant="secondary" onClick={() => router.push(backHref)}>
|
||||
{backLabel}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<div className="py-2">
|
||||
<Menu.Item>
|
||||
{({ active }) => (
|
||||
<a
|
||||
href="/admin/profile"
|
||||
className={`flex items-center px-4 py-3 text-sm transition-all duration-200 group ${
|
||||
active
|
||||
? 'bg-neutral-100 dark:bg-neutral-800/50 text-neutral-900 dark:text-white'
|
||||
: 'text-neutral-600 dark:text-neutral-300'
|
||||
}`}
|
||||
>
|
||||
<svg className="mr-3 h-4 w-4 group-hover:scale-110 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||
</svg>
|
||||
Mon profil
|
||||
</a>
|
||||
)}
|
||||
</Menu.Item>
|
||||
|
||||
<div className="border-t border-neutral-200 dark:border-neutral-700/50 mt-2 pt-2">
|
||||
<Menu.Item>
|
||||
{({ active }) => (
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className={`w-full flex items-center px-4 py-3 text-sm transition-all duration-200 group text-left ${
|
||||
active
|
||||
? 'bg-red-500/10 text-red-300'
|
||||
: 'text-red-400'
|
||||
}`}
|
||||
>
|
||||
<svg className="mr-3 h-4 w-4 group-hover:scale-110 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} 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
|
||||
</button>
|
||||
)}
|
||||
</Menu.Item>
|
||||
{action}
|
||||
</div>
|
||||
</div>
|
||||
</Menu.Items>
|
||||
</Transition>
|
||||
</Menu>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import DashboardPage from './pages/DashboardPage.js';
|
||||
import UsersPage from './pages/UsersPage.js';
|
||||
import UserEditPage from './pages/UserEditPage.js';
|
||||
import ProfilePage from './pages/ProfilePage.js';
|
||||
|
||||
export default function AdminPagesClient({ params, user, dashboardStats = null }) {
|
||||
const parts = params?.admin || [];
|
||||
const page = parts[0] || 'dashboard';
|
||||
|
||||
if (page === 'users' && parts[1] === 'edit' && parts[2]) {
|
||||
return <UserEditPage userId={parts[2]} user={user} />;
|
||||
}
|
||||
|
||||
const corePages = {
|
||||
dashboard: () => <DashboardPage user={user} stats={dashboardStats} />,
|
||||
users: () => <UsersPage user={user} />,
|
||||
profile: () => <ProfilePage user={user} />,
|
||||
};
|
||||
|
||||
const CorePageComponent = corePages[page];
|
||||
return CorePageComponent ? <CorePageComponent /> : <DashboardPage user={user} stats={dashboardStats} />;
|
||||
}
|
||||
+14
-7
@@ -1,25 +1,32 @@
|
||||
'use client';
|
||||
|
||||
import AdminSidebar from './AdminSidebar';
|
||||
import { useState } from 'react';
|
||||
import AdminHeader from './AdminHeader';
|
||||
import AdminSidebar from './AdminSidebar.js';
|
||||
import AdminTop from './AdminTop.js';
|
||||
|
||||
export default function AdminPagesLayout({ children, user, onLogout, appName, enabledModules, navigationSections }) {
|
||||
export default function AdminShell({ children, user, onLogout, appName, navigationSections, bottomNavItems }) {
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden bg-white dark:bg-black">
|
||||
<div className="flex h-screen overflow-hidden bg-white dark:bg-black font-ibm-plex-sans">
|
||||
<AdminSidebar
|
||||
isMobileMenuOpen={isMobileMenuOpen}
|
||||
setIsMobileMenuOpen={setIsMobileMenuOpen}
|
||||
appName={appName}
|
||||
enabledModules={enabledModules}
|
||||
navigationSections={navigationSections}
|
||||
bottomNavItems={bottomNavItems}
|
||||
/>
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
<AdminHeader isMobileMenuOpen={isMobileMenuOpen} setIsMobileMenuOpen={setIsMobileMenuOpen} user={user} onLogout={onLogout} appName={appName} />
|
||||
<AdminTop
|
||||
isMobileMenuOpen={isMobileMenuOpen}
|
||||
setIsMobileMenuOpen={setIsMobileMenuOpen}
|
||||
user={user}
|
||||
onLogout={onLogout}
|
||||
appName={appName}
|
||||
navigationSections={navigationSections}
|
||||
/>
|
||||
<main className="flex-1 overflow-y-auto bg-neutral-50 dark:bg-black">
|
||||
<div className="px-4 sm:px-6 lg:px-8 xl:px-12 pt-4 sm:pt-6 lg:pt-8 pb-32 max-w-[1400px] mx-auto">
|
||||
<div className="px-8 py-7 pb-32 max-w-[1920px] mx-auto">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
@@ -4,61 +4,58 @@ import React, { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import * as Icons from '@zen/core/shared/icons';
|
||||
import { ChevronDownIcon } from '@zen/core/shared/icons';
|
||||
import { ArrowDown01Icon } from '@zen/core/shared/icons';
|
||||
|
||||
/**
|
||||
* Resolve icon name (string) to icon component
|
||||
* Icons are passed as strings from server to avoid serialization issues
|
||||
*/
|
||||
function resolveIcon(iconNameOrComponent) {
|
||||
// If it's already a component (function), return it
|
||||
if (typeof iconNameOrComponent === 'function') {
|
||||
return iconNameOrComponent;
|
||||
}
|
||||
// If it's a string, look up in Icons
|
||||
if (typeof iconNameOrComponent === 'string') {
|
||||
return Icons[iconNameOrComponent] || Icons.DashboardSquare03Icon;
|
||||
}
|
||||
// Default fallback
|
||||
return Icons.DashboardSquare03Icon;
|
||||
}
|
||||
|
||||
const AdminSidebar = ({ isMobileMenuOpen, setIsMobileMenuOpen, appName, enabledModules, navigationSections: serverNavigationSections }) => {
|
||||
const AdminSidebar = ({ isMobileMenuOpen, setIsMobileMenuOpen, appName, enabledModules, navigationSections: serverNavigationSections, bottomNavItems = [] }) => {
|
||||
const pathname = usePathname();
|
||||
|
||||
// State to manage collapsed sections (all open by default)
|
||||
const [collapsedSections, setCollapsedSections] = useState(new Set());
|
||||
const isItemActive = (item) => {
|
||||
const basePath = item.basePath || item.href;
|
||||
return pathname === item.href || pathname === basePath || pathname.startsWith(basePath + '/');
|
||||
};
|
||||
|
||||
const [collapsedSections, setCollapsedSections] = useState(() => {
|
||||
const initial = new Set();
|
||||
serverNavigationSections.forEach(section => {
|
||||
const isActive = section.items.some(isItemActive);
|
||||
if (!isActive) initial.add(section.id);
|
||||
});
|
||||
return initial;
|
||||
});
|
||||
|
||||
// Function to toggle a section's state
|
||||
const toggleSection = (sectionId) => {
|
||||
// Find the section to check if it has active items
|
||||
const section = navigationSections.find(s => s.id === sectionId);
|
||||
|
||||
// Don't allow collapsing sections with active items
|
||||
if (section && isSectionActive(section)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setCollapsedSections(prev => {
|
||||
const newCollapsed = new Set(prev);
|
||||
if (newCollapsed.has(sectionId)) {
|
||||
newCollapsed.delete(sectionId);
|
||||
const next = new Set(prev);
|
||||
if (next.has(sectionId)) {
|
||||
next.delete(sectionId);
|
||||
} else {
|
||||
newCollapsed.add(sectionId);
|
||||
next.add(sectionId);
|
||||
}
|
||||
return newCollapsed;
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// Handle mobile menu closure when clicking on a link
|
||||
const handleMobileLinkClick = () => {
|
||||
setIsMobileMenuOpen(false);
|
||||
};
|
||||
|
||||
// Close mobile menu on screen size change
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
if (window.innerWidth >= 1024) { // lg breakpoint
|
||||
if (window.innerWidth >= 1024) {
|
||||
setIsMobileMenuOpen(false);
|
||||
}
|
||||
};
|
||||
@@ -67,49 +64,59 @@ const AdminSidebar = ({ isMobileMenuOpen, setIsMobileMenuOpen, appName, enabledM
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, [setIsMobileMenuOpen]);
|
||||
|
||||
// Function to check if any item in a section is currently active
|
||||
const isSectionActive = (section) => {
|
||||
return section.items.some(item => item.current);
|
||||
};
|
||||
|
||||
// Function to check if a section should be rendered as a direct link
|
||||
const shouldRenderAsDirectLink = (section) => {
|
||||
// Check if there's only one item and it has the same name as the section
|
||||
return section.items.length === 1 &&
|
||||
section.items[0].name.toLowerCase() === section.title.toLowerCase();
|
||||
};
|
||||
|
||||
// Update collapsed sections when pathname changes to ensure active sections are open
|
||||
useEffect(() => {
|
||||
setCollapsedSections(prev => {
|
||||
const newSet = new Set(prev);
|
||||
// Add any sections that have active items to ensure they stay open
|
||||
navigationSections.forEach(section => {
|
||||
if (isSectionActive(section)) {
|
||||
newSet.add(section.id);
|
||||
}
|
||||
});
|
||||
return newSet;
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [pathname]);
|
||||
// Recalcule `current` côté client pour suivre les navigations Link sans
|
||||
// dépendre d'un re-render serveur. Règle alignée sur navigation.js :
|
||||
// match le plus long (href exact > basePath préfixe), un seul item actif.
|
||||
const matchLen = (item) => {
|
||||
if (pathname === item.href) return item.href.length;
|
||||
const basePath = item.basePath || item.href;
|
||||
if (pathname === basePath) return basePath.length;
|
||||
if (pathname.startsWith(basePath + '/')) return basePath.length;
|
||||
return 0;
|
||||
};
|
||||
|
||||
let activeItemRef = null;
|
||||
let activeItemLen = 0;
|
||||
for (const section of serverNavigationSections) {
|
||||
for (const item of section.items) {
|
||||
const len = matchLen(item);
|
||||
if (len > activeItemLen) {
|
||||
activeItemRef = item;
|
||||
activeItemLen = len;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Use server-provided navigation sections if available, otherwise use core-only fallback
|
||||
// Server navigation includes module navigation, fallback only has core pages
|
||||
// Update the 'current' property based on the actual pathname (client-side)
|
||||
const navigationSections = serverNavigationSections.map(section => ({
|
||||
...section,
|
||||
items: section.items.map(item => ({
|
||||
...item,
|
||||
current: pathname === item.href || pathname.startsWith(item.href + '/')
|
||||
current: item === activeItemRef,
|
||||
}))
|
||||
}));
|
||||
|
||||
// Function to render a complete navigation section
|
||||
const base = 'w-full flex items-center justify-between px-[10px] py-[7px] rounded-lg text-[13px] transition-colors duration-[120ms] ease-out';
|
||||
const inactive = 'font-normal text-neutral-500 dark:text-neutral-400 hover:bg-neutral-100 dark:hover:bg-neutral-900 hover:text-neutral-900 dark:hover:text-white';
|
||||
|
||||
const parentBase = `${base}`;
|
||||
const parentActif = 'bg-neutral-100 dark:bg-neutral-900 text-black dark:text-white font-medium';
|
||||
const parentActifOuvert = 'text-black dark:text-white font-medium hover:bg-neutral-100 dark:hover:bg-neutral-900';
|
||||
|
||||
const subItemBase = base;
|
||||
const subItemActif = 'bg-neutral-100 dark:bg-neutral-900 text-black dark:text-white font-medium';
|
||||
|
||||
const renderNavSection = (section) => {
|
||||
const Icon = resolveIcon(section.icon);
|
||||
|
||||
// If section should be rendered as a direct link
|
||||
if (shouldRenderAsDirectLink(section)) {
|
||||
const item = section.items[0];
|
||||
return (
|
||||
@@ -117,18 +124,14 @@ const AdminSidebar = ({ isMobileMenuOpen, setIsMobileMenuOpen, appName, enabledM
|
||||
<Link
|
||||
href={item.href}
|
||||
onClick={handleMobileLinkClick}
|
||||
className={`${
|
||||
item.current
|
||||
? 'bg-neutral-100 dark:bg-neutral-800 text-neutral-900 dark:text-white'
|
||||
: 'text-neutral-900 dark:text-white hover:text-neutral-500 dark:hover:text-neutral-300'
|
||||
} w-full flex items-center justify-between border-y border-neutral-200 dark:border-neutral-800/70 px-4 py-2.5 -mb-[1px] text-[13px] tracking-wide transition-colorsduration-0`}
|
||||
className={`${parentBase} ${item.current ? parentActif : inactive}`}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<Icon className="mr-3 h-4 w-4 flex-shrink-0" />
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className="h-[15px] w-[15px] flex-shrink-0" />
|
||||
<span>{section.title}</span>
|
||||
</div>
|
||||
{item.badge && (
|
||||
<span className="bg-red-500 text-white text-xs px-1.5 py-0.5 rounded-full font-medium">
|
||||
<span className="bg-red-500 text-white text-[10px] px-1.5 py-0.5 rounded-lg font-medium">
|
||||
{item.badge}
|
||||
</span>
|
||||
)}
|
||||
@@ -137,33 +140,33 @@ const AdminSidebar = ({ isMobileMenuOpen, setIsMobileMenuOpen, appName, enabledM
|
||||
);
|
||||
}
|
||||
|
||||
// Regular section with expandable sub-items
|
||||
const isCollapsed = !collapsedSections.has(section.id);
|
||||
const isCollapsed = collapsedSections.has(section.id);
|
||||
const isActive = isSectionActive(section);
|
||||
|
||||
return (
|
||||
<div key={section.id}>
|
||||
<button
|
||||
onClick={() => toggleSection(section.id)}
|
||||
className="cursor-pointer w-full flex items-center justify-between border-y border-neutral-200 dark:border-neutral-800/70 px-4 py-2.5 -mb-[1px] text-[13px] text-neutral-900 dark:text-white tracking-wide hover:text-neutral-500 dark:hover:text-neutral-300 transition-colorsduration-0"
|
||||
className={`cursor-pointer ${parentBase} ${isActive && isCollapsed ? parentActif : isActive ? parentActifOuvert : inactive}`}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<Icon className="mr-3 h-4 w-4 flex-shrink-0" />
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className="h-[15px] w-[15px] flex-shrink-0" />
|
||||
<span>{section.title}</span>
|
||||
</div>
|
||||
<ChevronDownIcon
|
||||
className={`h-3 w-3 ${
|
||||
<ArrowDown01Icon
|
||||
className={`h-3 w-3 transition-transform duration-[120ms] ease-out ${
|
||||
isCollapsed ? '-rotate-90' : 'rotate-0'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
<div
|
||||
className={`overflow-hidden ${
|
||||
className={`overflow-hidden transition-all duration-[120ms] ease-out ${
|
||||
isCollapsed
|
||||
? 'max-h-0 opacity-0'
|
||||
: 'max-h-[1000px] opacity-100'
|
||||
}`}
|
||||
>
|
||||
<ul className="flex flex-col gap-0">
|
||||
<ul className="flex flex-col">
|
||||
{section.items.map(renderNavItem)}
|
||||
</ul>
|
||||
</div>
|
||||
@@ -171,26 +174,19 @@ const AdminSidebar = ({ isMobileMenuOpen, setIsMobileMenuOpen, appName, enabledM
|
||||
);
|
||||
};
|
||||
|
||||
// Function to render a navigation item
|
||||
const renderNavItem = (item) => {
|
||||
const Icon = resolveIcon(item.icon);
|
||||
return (
|
||||
<li key={item.name}>
|
||||
<Link
|
||||
href={item.href}
|
||||
onClick={handleMobileLinkClick}
|
||||
className={`${
|
||||
item.current
|
||||
? 'bg-neutral-100 dark:bg-neutral-800 text-neutral-900 dark:text-white'
|
||||
: 'text-neutral-500 dark:text-neutral-400 hover:bg-neutral-100 dark:hover:bg-neutral-800/50 hover:text-neutral-900 dark:hover:text-white'
|
||||
} group flex items-center justify-between px-4 py-1.5 text-[12px] font-medium transition-allduration-0`}
|
||||
className={`${subItemBase} ${item.current ? subItemActif : inactive}`}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<Icon className="mr-3 h-3.5 w-3.5 flex-shrink-0" />
|
||||
{item.name}
|
||||
<span className="pl-[25px]">{item.name}</span>
|
||||
</div>
|
||||
{item.badge && (
|
||||
<span className="bg-red-500 text-white text-xs px-1.5 py-0.5 rounded-full font-medium">
|
||||
<span className="bg-red-500 text-white text-[10px] px-1.5 py-0.5 rounded-lg font-medium">
|
||||
{item.badge}
|
||||
</span>
|
||||
)}
|
||||
@@ -204,7 +200,7 @@ const AdminSidebar = ({ isMobileMenuOpen, setIsMobileMenuOpen, appName, enabledM
|
||||
{/* Mobile overlay */}
|
||||
{isMobileMenuOpen && (
|
||||
<div
|
||||
className="lg:hidden fixed inset-0 bg-black/50 backdrop-blur-sm z-40"
|
||||
className="lg:hidden fixed inset-0 bg-black/50 z-40"
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
/>
|
||||
)}
|
||||
@@ -212,20 +208,39 @@ const AdminSidebar = ({ isMobileMenuOpen, setIsMobileMenuOpen, appName, enabledM
|
||||
{/* Sidebar */}
|
||||
<div className={`
|
||||
${isMobileMenuOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'}
|
||||
fixed lg:static inset-y-0 left-0 z-40 w-64 bg-white dark:bg-black border-r border-neutral-200 dark:border-neutral-800/70 flex flex-col h-screen transition-transform duration-300 ease-in-out
|
||||
fixed lg:static inset-y-0 left-0 z-40 w-[230px] bg-white dark:bg-black border-r border-neutral-200 dark:border-neutral-800/70 flex flex-col h-screen transition-transform duration-[120ms] ease-out
|
||||
`}>
|
||||
{/* Logo Section */}
|
||||
<Link href="/admin" className="px-4 h-14 flex items-center justify-start gap-2 border-b border-neutral-200 dark:border-neutral-800/70">
|
||||
<h1 className="text-neutral-900 dark:text-white font-semibold">{appName}</h1>
|
||||
<span className="bg-red-700/10 border border-red-600/20 text-red-600 uppercase text-[10px] leading-none px-2 py-1 rounded-full font-semibold">
|
||||
Admin
|
||||
</span>
|
||||
{/* Logo */}
|
||||
<Link href="/admin/dashboard" className="px-4 h-12 flex items-center justify-start gap-2 border-b border-neutral-200 dark:border-neutral-800/70">
|
||||
<h1 className="text-neutral-900 dark:text-white font-semibold text-sm">{appName}</h1>
|
||||
</Link>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 px-0 overflow-y-auto flex flex-col gap-0 pb-12 -mt-[1px]">
|
||||
<nav className="flex-1 px-2 py-2 overflow-y-auto flex flex-col">
|
||||
{navigationSections.map(renderNavSection)}
|
||||
</nav>
|
||||
|
||||
{/* Bottom pinned items */}
|
||||
{bottomNavItems.length > 0 && (
|
||||
<div className="px-2 py-2 border-t border-neutral-200 dark:border-neutral-800/70 shrink-0">
|
||||
{bottomNavItems.map((item) => {
|
||||
const Icon = resolveIcon(item.icon);
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
onClick={handleMobileLinkClick}
|
||||
className={`${base} ${item.current ? parentActif : inactive}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className="h-[15px] w-[15px] flex-shrink-0" />
|
||||
<span>{item.name}</span>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
'use client';
|
||||
|
||||
import { Fragment, useState, useEffect } from 'react';
|
||||
import { Menu, MenuButton, MenuItem, MenuItems, Transition } from '@headlessui/react';
|
||||
import { ArrowDown01Icon, ArrowRight01Icon, Menu01Icon, User03Icon, DashboardSquare03Icon, Logout02Icon } from '@zen/core/shared/icons';
|
||||
import { UserAvatar } from '@zen/core/shared/components';
|
||||
import { useRouter, usePathname } from 'next/navigation';
|
||||
import { getPage } from '../registry.js';
|
||||
import { useTheme, getThemeIcon } from '@zen/core/themes';
|
||||
import Link from 'next/link';
|
||||
|
||||
const AdminTop = ({ isMobileMenuOpen, setIsMobileMenuOpen, user, onLogout, appName = 'ZEN', navigationSections = [] }) => {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const [pageTitle, setPageTitle] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const segments = pathname.replace(/^\/admin\/?/, '').split('/').filter(Boolean);
|
||||
const slug = segments[0] || 'dashboard';
|
||||
setPageTitle(getPage(slug)?.title || '');
|
||||
}, [pathname]);
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
if (onLogout) {
|
||||
const result = await onLogout();
|
||||
if (result && result.success) {
|
||||
router.push('/auth/login');
|
||||
} else {
|
||||
console.error('Logout failed:', result?.error);
|
||||
router.push('/auth/login');
|
||||
}
|
||||
} else {
|
||||
router.push('/auth/login');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Logout error:', error);
|
||||
router.push('/auth/login');
|
||||
}
|
||||
};
|
||||
|
||||
const { theme, toggle, systemIsDark } = useTheme();
|
||||
const ThemeIcon = getThemeIcon(theme, systemIsDark);
|
||||
const themeLabel = theme === 'light' ? 'Mode clair' : theme === 'dark' ? 'Mode sombre' : 'Thème système';
|
||||
|
||||
const buildBreadcrumbs = () => {
|
||||
const crumbs = [{ icon: DashboardSquare03Icon, href: '/admin/dashboard' }];
|
||||
const after = pathname.replace(/^\/admin\/?/, '');
|
||||
const segments = after.split('/').filter(Boolean);
|
||||
|
||||
if (!after || !segments.length || (segments[0] === 'dashboard' && segments.length === 1)) {
|
||||
crumbs.push({ label: pageTitle });
|
||||
return crumbs;
|
||||
}
|
||||
|
||||
// Localise l'item actif via match le plus long (href exact > basePath préfixe).
|
||||
// On recalcule côté client pour rester aligné sur AdminSidebar et suivre les
|
||||
// navigations Link sans dépendre d'un re-render serveur du layout.
|
||||
const matchLen = (item) => {
|
||||
if (pathname === item.href) return item.href.length;
|
||||
const basePath = item.basePath || item.href;
|
||||
if (pathname === basePath) return basePath.length;
|
||||
if (pathname.startsWith(basePath + '/')) return basePath.length;
|
||||
return 0;
|
||||
};
|
||||
|
||||
let activeSection = null;
|
||||
let activeItem = null;
|
||||
let activeLen = 0;
|
||||
for (const section of navigationSections) {
|
||||
for (const item of section.items) {
|
||||
const len = matchLen(item);
|
||||
if (len > activeLen) {
|
||||
activeSection = section;
|
||||
activeItem = item;
|
||||
activeLen = len;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!activeItem) {
|
||||
// Page enregistrée hors navigation (ex : /admin/profile).
|
||||
crumbs.push({ label: pageTitle });
|
||||
return crumbs;
|
||||
}
|
||||
|
||||
// Préfixer la section uniquement si elle ne se résume pas à un item unique
|
||||
// du même nom (cas « direct link » dans la sidebar — section.title === item.name,
|
||||
// ex : section "Utilisateurs" + item "Utilisateurs").
|
||||
if (activeSection.title !== activeItem.name) {
|
||||
crumbs.push({ label: activeSection.title });
|
||||
}
|
||||
|
||||
const isExactPage = pathname === activeItem.href;
|
||||
crumbs.push({ label: activeItem.name, href: isExactPage ? undefined : activeItem.href });
|
||||
|
||||
// Action de sous-route : segment juste après le basePath (/edit/:id, /new).
|
||||
const basePath = activeItem.basePath || activeItem.href;
|
||||
const trail = pathname.startsWith(basePath)
|
||||
? pathname.slice(basePath.length).split('/').filter(Boolean)
|
||||
: [];
|
||||
const action = trail[0];
|
||||
if (action === 'new') {
|
||||
const page = getPage(`${segments[0]}:new`);
|
||||
crumbs.push({ label: page?.breadcrumbLabel || page?.title || 'Nouveau' });
|
||||
} else if (action === 'edit') {
|
||||
const page = getPage(`${segments[0]}:edit`);
|
||||
crumbs.push({ label: page?.breadcrumbLabel || page?.title || 'Modification' });
|
||||
}
|
||||
|
||||
return crumbs;
|
||||
};
|
||||
|
||||
const breadcrumbs = buildBreadcrumbs();
|
||||
|
||||
return (
|
||||
<header className="bg-white dark:bg-black border-b border-neutral-200 dark:border-neutral-800/70 sticky top-0 z-30 h-12 flex items-center w-full">
|
||||
<div className="flex items-center justify-between px-4 lg:px-6 py-2 w-full">
|
||||
{/* Left section — Mobile menu button + Logo / Desktop breadcrumb */}
|
||||
<div className="flex items-center space-x-3 lg:hidden">
|
||||
<button
|
||||
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
|
||||
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"
|
||||
>
|
||||
<Menu01Icon className="h-5 w-5 transition-transform duration-200" />
|
||||
</button>
|
||||
<h1 className="text-neutral-900 dark:text-white font-semibold text-sm">{appName}</h1>
|
||||
</div>
|
||||
|
||||
{/* Desktop breadcrumb — always rendered to keep user menu pinned right */}
|
||||
<div className="hidden lg:flex items-center gap-1.5 text-[13px]">
|
||||
{breadcrumbs.length > 0 && breadcrumbs.map((crumb, i) => (
|
||||
<Fragment key={i}>
|
||||
{i > 0 && (
|
||||
<ArrowRight01Icon className="w-3 h-3 text-neutral-400 dark:text-neutral-600 flex-shrink-0" />
|
||||
)}
|
||||
{crumb.icon ? (
|
||||
<button
|
||||
onClick={() => router.push(crumb.href)}
|
||||
className="text-neutral-500 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white transition-colors cursor-pointer"
|
||||
>
|
||||
<crumb.icon className="w-4 h-4" />
|
||||
</button>
|
||||
) : crumb.href ? (
|
||||
<button
|
||||
onClick={() => router.push(crumb.href)}
|
||||
className="text-neutral-500 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white transition-colors cursor-pointer"
|
||||
>
|
||||
{crumb.label}
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-neutral-900 dark:text-white font-medium">{crumb.label}</span>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Right section — Profile */}
|
||||
<div className="flex items-center gap-3 sm:gap-4">
|
||||
{/* User Profile Menu */}
|
||||
<Menu as="div" className="relative">
|
||||
<MenuButton className="cursor-pointer flex items-center gap-2.5 px-2.5 py-1.5 rounded-xl hover:bg-black/5 dark:hover:bg-white/5 transition-colors duration-200 outline-none group">
|
||||
<UserAvatar user={user} size="sm" />
|
||||
{/* Name — desktop only */}
|
||||
<span className="hidden sm:block text-[13px] leading-none font-medium text-neutral-800 dark:text-white">
|
||||
{user?.name || 'User'}
|
||||
</span>
|
||||
<ArrowDown01Icon className="w-3.5 h-3.5 shrink-0 text-neutral-400 dark:text-neutral-600 transition-transform duration-200 group-data-open:rotate-180" />
|
||||
</MenuButton>
|
||||
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-150"
|
||||
enterFrom="opacity-0 translate-y-1"
|
||||
enterTo="opacity-100 translate-y-0"
|
||||
leave="transition ease-in duration-100"
|
||||
leaveFrom="opacity-100 translate-y-0"
|
||||
leaveTo="opacity-0 translate-y-1"
|
||||
>
|
||||
<MenuItems className="absolute right-0 mt-4 w-48 outline-none rounded-xl border border-black/8 dark:border-white/8 bg-white dark:bg-[#0B0B0B] shadow-lg overflow-hidden z-50">
|
||||
<div className="p-1.5 flex flex-col gap-0.5">
|
||||
{/* Profile */}
|
||||
<MenuItem>
|
||||
<Link
|
||||
href="/admin/profile"
|
||||
className="cursor-pointer w-full flex items-center gap-2 px-[7px] py-[10px] rounded-lg text-[13px] leading-none text-neutral-500 dark:text-neutral-400 transition-colors duration-[120ms] ease-out data-focus:bg-neutral-100 dark:data-focus:bg-white/5 data-focus:text-neutral-900 dark:data-focus:text-white"
|
||||
>
|
||||
<User03Icon className="w-4 h-4 shrink-0" />
|
||||
Mon profil
|
||||
</Link>
|
||||
</MenuItem>
|
||||
|
||||
{/* Theme — pas de MenuItem pour ne pas fermer le menu au clic */}
|
||||
<button
|
||||
onClick={toggle}
|
||||
className="cursor-pointer w-full flex items-center gap-2 px-[7px] py-[10px] rounded-lg text-[13px] leading-none text-neutral-500 dark:text-neutral-400 hover:bg-amber-50 dark:hover:bg-amber-500/10 hover:text-amber-500 dark:hover:text-amber-400 transition-colors duration-150"
|
||||
>
|
||||
<ThemeIcon className="w-4 h-4 shrink-0" />
|
||||
{themeLabel}
|
||||
</button>
|
||||
|
||||
<div className="h-px bg-black/6 dark:bg-white/6 my-0.5" />
|
||||
|
||||
{/* Logout */}
|
||||
<MenuItem>
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<Logout02Icon className="w-4 h-4 shrink-0" />
|
||||
Se déconnecter
|
||||
</button>
|
||||
</MenuItem>
|
||||
</div>
|
||||
</MenuItems>
|
||||
</Transition>
|
||||
</Menu>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminTop;
|
||||
@@ -0,0 +1,189 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Input, Textarea, Switch, Modal, ColorPicker } from '@zen/core/shared/components';
|
||||
import { useToast } from '@zen/core/toast';
|
||||
|
||||
const RoleEditModal = ({ roleId, isOpen, onClose, onSaved }) => {
|
||||
const toast = useToast();
|
||||
const isNew = !roleId || roleId === 'new';
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [isSystem, setIsSystem] = useState(false);
|
||||
|
||||
const [name, setName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [color, setColor] = useState('#6b7280');
|
||||
const [selectedPerms, setSelectedPerms] = useState([]);
|
||||
// Catalogue dynamique des permissions (core + modules), récupéré via l'API.
|
||||
const [permissionGroups, setPermissionGroups] = useState({});
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
fetchPermissions();
|
||||
if (isNew) {
|
||||
setName('');
|
||||
setDescription('');
|
||||
setColor('#6b7280');
|
||||
setSelectedPerms([]);
|
||||
setIsSystem(false);
|
||||
return;
|
||||
}
|
||||
fetchRole();
|
||||
}, [isOpen, roleId]);
|
||||
|
||||
const fetchPermissions = async () => {
|
||||
try {
|
||||
const response = await fetch('/zen/api/permissions', { credentials: 'include' });
|
||||
if (!response.ok) return;
|
||||
const data = await response.json();
|
||||
setPermissionGroups(data.groups || {});
|
||||
} catch {
|
||||
// Si le catalogue n'est pas joignable, on laisse l'utilisateur sauvegarder
|
||||
// ses changements ; les permissions invalides sont filtrées côté serveur.
|
||||
}
|
||||
};
|
||||
|
||||
const fetchRole = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch(`/zen/api/roles/${roleId}`, { credentials: 'include' });
|
||||
if (!response.ok) {
|
||||
toast.error('Rôle introuvable');
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
const data = await response.json();
|
||||
const role = data.role;
|
||||
setName(role.name || '');
|
||||
setDescription(role.description || '');
|
||||
setColor(role.color || '#6b7280');
|
||||
setSelectedPerms(role.permission_keys || []);
|
||||
setIsSystem(role.is_system || false);
|
||||
} catch {
|
||||
toast.error('Impossible de charger ce rôle');
|
||||
onClose();
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const togglePerm = (key) => {
|
||||
setSelectedPerms(prev =>
|
||||
prev.includes(key) ? prev.filter(k => k !== key) : [...prev, key]
|
||||
);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!name.trim()) {
|
||||
toast.error('Le nom du rôle est requis');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setSaving(true);
|
||||
const url = isNew ? '/zen/api/roles' : `/zen/api/roles/${roleId}`;
|
||||
const method = isNew ? 'POST' : 'PUT';
|
||||
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: name.trim(),
|
||||
description: description.trim() || null,
|
||||
color,
|
||||
permissionKeys: selectedPerms,
|
||||
}),
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok) {
|
||||
toast.error(data.message || 'Impossible de sauvegarder ce rôle');
|
||||
return;
|
||||
}
|
||||
toast.success(isNew ? 'Rôle créé' : 'Rôle mis à jour');
|
||||
onSaved?.();
|
||||
onClose();
|
||||
} catch {
|
||||
toast.error('Impossible de sauvegarder ce rôle');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const title = isNew ? 'Nouveau rôle' : `Modifier "${name}"`;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={title}
|
||||
onSubmit={handleSubmit}
|
||||
submitLabel={isNew ? 'Créer le rôle' : 'Sauvegarder'}
|
||||
loading={saving}
|
||||
disabled={loading}
|
||||
size="lg"
|
||||
>
|
||||
{loading ? (
|
||||
<div className="flex flex-col gap-4 animate-pulse">
|
||||
<div className="h-10 bg-neutral-100 dark:bg-neutral-800 rounded-lg" />
|
||||
<div className="h-20 bg-neutral-100 dark:bg-neutral-800 rounded-lg" />
|
||||
<div className="h-40 bg-neutral-100 dark:bg-neutral-800 rounded-lg" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-4">
|
||||
<Input
|
||||
label="Nom du rôle"
|
||||
value={name}
|
||||
onChange={setName}
|
||||
placeholder="Éditeur, Modérateur..."
|
||||
required
|
||||
/>
|
||||
<Textarea
|
||||
label="Description"
|
||||
value={description}
|
||||
onChange={setDescription}
|
||||
rows={2}
|
||||
placeholder="Description optionnelle..."
|
||||
/>
|
||||
<ColorPicker
|
||||
label="Couleur du rôle"
|
||||
description="Les membres utilisent la couleur du rôle le plus élevé qu'ils possèdent."
|
||||
value={color}
|
||||
onChange={setColor}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-500 dark:text-neutral-400">Permissions</p>
|
||||
{Object.entries(permissionGroups).map(([group, perms]) => (
|
||||
<div key={group} className="rounded-xl border border-neutral-200 dark:border-neutral-700/60 overflow-hidden">
|
||||
<div className="px-4 py-2.5 bg-neutral-50 dark:bg-neutral-800/60 border-b border-neutral-200 dark:border-neutral-700/60">
|
||||
<p className="text-[11px] font-medium font-ibm-plex-mono text-neutral-500 dark:text-neutral-400 uppercase tracking-wide">
|
||||
{group}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col divide-y divide-neutral-100 dark:divide-neutral-700/40">
|
||||
{perms.map((perm) => (
|
||||
<Switch
|
||||
key={perm.key}
|
||||
checked={selectedPerms.includes(perm.key)}
|
||||
onChange={() => togglePerm(perm.key)}
|
||||
label={perm.name}
|
||||
description={perm.description}
|
||||
disabled={isSystem}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default RoleEditModal;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,311 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Input, TagInput, Modal, Badge } from '@zen/core/shared/components';
|
||||
import { useToast } from '@zen/core/toast';
|
||||
|
||||
const UserEditModal = ({ userId, currentUserId, isOpen, onClose, onSaved }) => {
|
||||
const toast = useToast();
|
||||
const isSelf = userId && currentUserId && userId === currentUserId;
|
||||
|
||||
const [userData, setUserData] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const [formData, setFormData] = useState({ name: '', email: '', currentPassword: '', newPassword: '' });
|
||||
const [errors, setErrors] = useState({});
|
||||
const [sendingReset, setSendingReset] = useState(false);
|
||||
|
||||
const [allRoles, setAllRoles] = useState([]);
|
||||
const [selectedRoleIds, setSelectedRoleIds] = useState([]);
|
||||
const [initialRoleIds, setInitialRoleIds] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || !userId) return;
|
||||
loadAll();
|
||||
}, [isOpen, userId]);
|
||||
|
||||
const loadAll = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setErrors({});
|
||||
const [userRes, rolesRes, userRolesRes] = await Promise.all([
|
||||
fetch(`/zen/api/users/${userId}`, { credentials: 'include' }),
|
||||
fetch('/zen/api/roles', { credentials: 'include' }),
|
||||
fetch(`/zen/api/users/${userId}/roles`, { credentials: 'include' }),
|
||||
]);
|
||||
const [userJson, rolesJson, userRolesJson] = await Promise.all([
|
||||
userRes.json(),
|
||||
rolesRes.json(),
|
||||
userRolesRes.json(),
|
||||
]);
|
||||
|
||||
if (userJson.user) {
|
||||
setUserData(userJson.user);
|
||||
setFormData({
|
||||
name: userJson.user.name || '',
|
||||
email: userJson.user.email || '',
|
||||
currentPassword: '',
|
||||
newPassword: '',
|
||||
});
|
||||
} else {
|
||||
toast.error(userJson.message || 'Utilisateur introuvable');
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
|
||||
setAllRoles(rolesJson.roles || []);
|
||||
|
||||
const ids = (userRolesJson.roles || []).map(r => r.id);
|
||||
setSelectedRoleIds(ids);
|
||||
setInitialRoleIds(ids);
|
||||
} catch {
|
||||
toast.error("Impossible de charger l'utilisateur");
|
||||
onClose();
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputChange = (field, value) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
if (errors[field]) setErrors(prev => ({ ...prev, [field]: null }));
|
||||
};
|
||||
|
||||
const handleSendPasswordReset = async () => {
|
||||
setSendingReset(true);
|
||||
try {
|
||||
const res = await fetch(`/zen/api/users/${userId}/send-password-reset`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || data.message || 'Impossible d\'envoyer le lien');
|
||||
toast.success(data.message || 'Lien de réinitialisation envoyé');
|
||||
} catch {
|
||||
toast.error('Impossible d\'envoyer le lien de réinitialisation');
|
||||
} finally {
|
||||
setSendingReset(false);
|
||||
}
|
||||
};
|
||||
|
||||
const emailChanged = userData && formData.email !== userData.email;
|
||||
|
||||
const needsCurrentPassword = isSelf && (emailChanged || !!formData.newPassword);
|
||||
|
||||
const validate = () => {
|
||||
const newErrors = {};
|
||||
if (!formData.name?.trim()) newErrors.name = 'Le nom est requis';
|
||||
if (needsCurrentPassword && !formData.currentPassword) newErrors.currentPassword = 'Le mot de passe actuel est requis';
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!validate()) return;
|
||||
try {
|
||||
setSaving(true);
|
||||
|
||||
const userRes = await fetch(`/zen/api/users/${userId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
name: formData.name.trim(),
|
||||
}),
|
||||
});
|
||||
const userResData = await userRes.json();
|
||||
if (!userRes.ok) {
|
||||
toast.error(userResData.message || userResData.error || "Impossible de mettre à jour l'utilisateur");
|
||||
return;
|
||||
}
|
||||
|
||||
const toAdd = selectedRoleIds.filter(id => !initialRoleIds.includes(id));
|
||||
const toRemove = initialRoleIds.filter(id => !selectedRoleIds.includes(id));
|
||||
|
||||
await Promise.all(
|
||||
toAdd.map(roleId =>
|
||||
fetch(`/zen/api/users/${userId}/roles`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ roleId }),
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
const removeResults = await Promise.all(
|
||||
toRemove.map(roleId =>
|
||||
fetch(`/zen/api/users/${userId}/roles/${roleId}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
}).then(async res => ({ res, data: await res.json() }))
|
||||
)
|
||||
);
|
||||
|
||||
const failedRemove = removeResults.find(({ res }) => !res.ok);
|
||||
if (failedRemove) {
|
||||
toast.error(failedRemove.data?.message || failedRemove.data?.error || 'Impossible de retirer ce rôle');
|
||||
onSaved?.();
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
|
||||
if (emailChanged) {
|
||||
if (isSelf) {
|
||||
const emailRes = await fetch('/zen/api/users/profile/email', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ newEmail: formData.email.trim(), password: formData.currentPassword }),
|
||||
});
|
||||
const emailData = await emailRes.json();
|
||||
if (!emailRes.ok) {
|
||||
toast.error(emailData.error || emailData.message || 'Impossible de changer le courriel');
|
||||
onSaved?.();
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
toast.success('Utilisateur mis à jour');
|
||||
toast.info(emailData.message || 'Un courriel de confirmation a été envoyé');
|
||||
} else {
|
||||
const emailRes = await fetch(`/zen/api/users/${userId}/email`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ newEmail: formData.email.trim() }),
|
||||
});
|
||||
const emailData = await emailRes.json();
|
||||
if (!emailRes.ok) {
|
||||
toast.error(emailData.error || emailData.message || 'Impossible de changer le courriel');
|
||||
onSaved?.();
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
toast.success('Utilisateur mis à jour');
|
||||
}
|
||||
} else {
|
||||
toast.success('Utilisateur mis à jour');
|
||||
}
|
||||
|
||||
if (formData.newPassword) {
|
||||
const pwdUrl = isSelf ? '/zen/api/users/profile/password' : `/zen/api/users/${userId}/password`;
|
||||
const pwdMethod = isSelf ? 'POST' : 'PUT';
|
||||
const pwdBody = isSelf
|
||||
? { currentPassword: formData.currentPassword, newPassword: formData.newPassword }
|
||||
: { newPassword: formData.newPassword };
|
||||
const pwdRes = await fetch(pwdUrl, {
|
||||
method: pwdMethod,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(pwdBody),
|
||||
});
|
||||
const pwdData = await pwdRes.json();
|
||||
if (!pwdRes.ok) {
|
||||
toast.error(pwdData.error || pwdData.message || 'Impossible de changer le mot de passe');
|
||||
onSaved?.();
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
toast.success('Mot de passe mis à jour');
|
||||
}
|
||||
|
||||
onSaved?.();
|
||||
onClose();
|
||||
} catch {
|
||||
toast.error("Impossible de mettre à jour 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="Modifier l'utilisateur"
|
||||
onSubmit={handleSubmit}
|
||||
submitLabel="Mettre à jour"
|
||||
loading={saving}
|
||||
disabled={loading}
|
||||
size="md"
|
||||
>
|
||||
{loading ? (
|
||||
<div className="flex flex-col gap-4 animate-pulse">
|
||||
<div className="h-10 bg-neutral-100 dark:bg-neutral-800 rounded-lg" />
|
||||
<div className="h-10 bg-neutral-100 dark:bg-neutral-800 rounded-lg" />
|
||||
<div className="h-10 bg-neutral-100 dark:bg-neutral-800 rounded-lg" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Nom *"
|
||||
value={formData.name}
|
||||
onChange={(value) => handleInputChange('name', value)}
|
||||
placeholder="Nom de l'utilisateur"
|
||||
error={errors.name}
|
||||
/>
|
||||
<Input
|
||||
label="Courriel"
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(value) => handleInputChange('email', value)}
|
||||
placeholder="courriel@exemple.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<TagInput
|
||||
label="Rôles attribués"
|
||||
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-2">
|
||||
<Input
|
||||
label="Nouveau mot de passe (optionnel)"
|
||||
type="password"
|
||||
value={formData.newPassword}
|
||||
onChange={(value) => handleInputChange('newPassword', value)}
|
||||
placeholder="Laisser vide pour ne pas modifier"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSendPasswordReset}
|
||||
disabled={sendingReset}
|
||||
className="cursor-pointer text-left text-xs text-neutral-500 hover:text-neutral-800 dark:hover:text-neutral-200 underline self-start transition-colors"
|
||||
>
|
||||
{sendingReset ? 'Envoi en cours…' : 'Envoyer un lien de réinitialisation par courriel'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{needsCurrentPassword && (
|
||||
<Input
|
||||
label="Mot de passe actuel *"
|
||||
type="password"
|
||||
value={formData.currentPassword}
|
||||
onChange={(value) => handleInputChange('currentPassword', value)}
|
||||
placeholder="Votre mot de passe"
|
||||
error={errors.currentPassword}
|
||||
description={emailChanged && formData.newPassword ? 'Requis pour confirmer le changement de courriel et de mot de passe' : emailChanged ? 'Requis pour confirmer le changement de courriel' : 'Requis pour confirmer le changement de mot de passe'}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserEditModal;
|
||||
@@ -1,6 +1,10 @@
|
||||
/**
|
||||
* Admin Components Exports
|
||||
*/
|
||||
'use client';
|
||||
|
||||
export { default as AdminPagesClient } from './AdminPages.js';
|
||||
export { default as AdminPagesLayout } from './AdminPagesLayout.js';
|
||||
export { default as AdminShell } from './AdminShell.js';
|
||||
export { default as AdminSidebar } from './AdminSidebar.js';
|
||||
export { default as AdminTop } from './AdminTop.js';
|
||||
export { default as AdminHeader } from './AdminHeader.js';
|
||||
export { default as ThemeToggle } from './ThemeToggle.js';
|
||||
export { default as UserEditModal } from './UserEditModal.client.js';
|
||||
export { default as RoleEditModal } from './RoleEditModal.client.js';
|
||||
export { default as UserCreateModal } from './UserCreateModal.client.js';
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { StatCard } from '@zen/core/shared/components';
|
||||
import { UserMultiple02Icon } from '@zen/core/shared/icons';
|
||||
|
||||
export default function DashboardPage({ user, stats }) {
|
||||
const loading = !stats;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 sm:gap-6 lg:gap-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-lg sm:text-xl font-semibold text-neutral-900 dark:text-white">
|
||||
Tableau de bord
|
||||
</h1>
|
||||
<p className="mt-1 text-xs text-neutral-400">Vue d'ensemble de votre application</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-6">
|
||||
<StatCard
|
||||
title="Nombre d'utilisateurs"
|
||||
value={loading ? '-' : String(stats?.totalUsers || 0)}
|
||||
icon={UserMultiple02Icon}
|
||||
color="text-purple-400"
|
||||
bgColor="bg-purple-500/10"
|
||||
loading={loading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,331 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Card, Input, Button } from '@zen/core/shared/components';
|
||||
import { useToast } from '@zen/core/toast';
|
||||
|
||||
const ProfilePage = ({ user: initialUser }) => {
|
||||
const toast = useToast();
|
||||
const [user, setUser] = useState(initialUser);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [uploadingImage, setUploadingImage] = useState(false);
|
||||
const [imagePreview, setImagePreview] = useState(null);
|
||||
const fileInputRef = useRef(null);
|
||||
const [formData, setFormData] = useState({
|
||||
name: initialUser?.name || ''
|
||||
});
|
||||
|
||||
// Helper function to get image URL from storage key
|
||||
const getImageUrl = (imageKey) => {
|
||||
if (!imageKey) return null;
|
||||
return `/zen/api/storage/${imageKey}`;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (initialUser) {
|
||||
setFormData({
|
||||
name: initialUser.name || ''
|
||||
});
|
||||
setImagePreview(getImageUrl(initialUser.image));
|
||||
}
|
||||
}, [initialUser]);
|
||||
|
||||
const handleChange = (value) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
name: value
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!formData.name.trim()) {
|
||||
toast.error('Le nom est requis');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const response = await fetch('/zen/api/users/profile', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
name: formData.name.trim()
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok || !data.success) {
|
||||
throw new Error(data.error || 'Échec de la mise à jour du profil');
|
||||
}
|
||||
|
||||
setUser(data.user);
|
||||
toast.success('Profil mis à jour avec succès');
|
||||
|
||||
// Refresh the page to update the user data in the header
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 1000);
|
||||
} catch (error) {
|
||||
console.error('Error updating profile:', error);
|
||||
toast.error(error.message || 'Échec de la mise à jour du profil');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setFormData({
|
||||
name: user?.name || ''
|
||||
});
|
||||
};
|
||||
|
||||
const handleImageSelect = async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
// Validate file type
|
||||
if (!file.type.startsWith('image/')) {
|
||||
toast.error('Veuillez sélectionner un fichier image');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file size (5MB)
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
toast.error("L'image doit faire moins de 5MB");
|
||||
return;
|
||||
}
|
||||
|
||||
// Show preview
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
setImagePreview(reader.result);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
|
||||
// Upload image
|
||||
setUploadingImage(true);
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const response = await fetch('/zen/api/users/profile/picture', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body: formData
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok || !data.success) {
|
||||
throw new Error(data.message || 'Échec du téléchargement de l\'image');
|
||||
}
|
||||
|
||||
setUser(data.user);
|
||||
setImagePreview(getImageUrl(data.user.image));
|
||||
toast.success('Photo de profil mise à jour avec succès');
|
||||
} catch (error) {
|
||||
console.error('Error uploading image:', error);
|
||||
toast.error(error.message || 'Échec du téléchargement de l\'image');
|
||||
// Revert preview on error
|
||||
setImagePreview(getImageUrl(user?.image));
|
||||
} finally {
|
||||
setUploadingImage(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveImage = async () => {
|
||||
if (!user?.image) return;
|
||||
|
||||
setUploadingImage(true);
|
||||
try {
|
||||
const response = await fetch('/zen/api/users/profile/picture', {
|
||||
method: 'DELETE',
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok || !data.success) {
|
||||
throw new Error(data.message || 'Échec de la suppression de l\'image');
|
||||
}
|
||||
|
||||
setUser(data.user);
|
||||
setImagePreview(null);
|
||||
toast.success('Photo de profil supprimée avec succès');
|
||||
} catch (error) {
|
||||
console.error('Error removing image:', error);
|
||||
toast.error(error.message || 'Échec de la suppression de l\'image');
|
||||
} finally {
|
||||
setUploadingImage(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getUserInitials = (name) => {
|
||||
if (!name) return 'U';
|
||||
return name
|
||||
.split(' ')
|
||||
.map(n => n[0])
|
||||
.join('')
|
||||
.toUpperCase()
|
||||
.slice(0, 2);
|
||||
};
|
||||
|
||||
const hasChanges = formData.name !== user?.name;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 sm:gap-6 lg:gap-8">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-lg sm:text-xl font-semibold text-neutral-900 dark:text-white">
|
||||
Mon profil
|
||||
</h1>
|
||||
<p className="mt-1 text-xs text-neutral-400">
|
||||
Gérez les informations de votre compte
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex flex-col gap-6">
|
||||
<Card>
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold text-neutral-900 dark:text-white">
|
||||
Photo de profil
|
||||
</h2>
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="relative">
|
||||
{imagePreview ? (
|
||||
<img
|
||||
src={imagePreview}
|
||||
alt="Profile"
|
||||
className="w-24 h-24 rounded-full object-cover border-2 border-neutral-300 dark:border-neutral-700"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-24 h-24 bg-gradient-to-br from-neutral-200 to-neutral-300 dark:from-neutral-800 dark:to-neutral-700 rounded-full flex items-center justify-center border-2 border-neutral-300 dark:border-neutral-700">
|
||||
<span className="text-neutral-700 dark:text-white font-semibold text-2xl">
|
||||
{getUserInitials(user?.name)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{uploadingImage && (
|
||||
<div className="absolute inset-0 bg-black/50 rounded-full flex items-center justify-center">
|
||||
<div className="w-8 h-8 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-sm text-neutral-400">
|
||||
Téléchargez une nouvelle photo de profil. Taille max 5MB.
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleImageSelect}
|
||||
className="hidden"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploadingImage}
|
||||
>
|
||||
{uploadingImage ? 'Téléchargement...' : 'Télécharger une image'}
|
||||
</Button>
|
||||
{imagePreview && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={handleRemoveImage}
|
||||
disabled={uploadingImage}
|
||||
>
|
||||
Supprimer
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold text-neutral-900 dark:text-white">
|
||||
Informations personnelles
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Nom complet"
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
placeholder="Entrez votre nom complet"
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Courriel"
|
||||
name="email"
|
||||
type="email"
|
||||
value={user?.email || ''}
|
||||
disabled
|
||||
readOnly
|
||||
description="L'email ne peut pas être modifié"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Compte créé"
|
||||
name="createdAt"
|
||||
type="text"
|
||||
value={user?.created_at ? new Date(user.created_at).toLocaleDateString('fr-FR', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
}) : 'N/D'}
|
||||
disabled
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex items-center justify-end gap-3 pt-4 border-t border-neutral-200 dark:border-neutral-700/50">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={handleReset}
|
||||
disabled={loading || !hasChanges}
|
||||
>
|
||||
Réinitialiser
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
disabled={loading || !hasChanges}
|
||||
loading={loading}
|
||||
>
|
||||
{loading ? 'Enregistrement...' : 'Enregistrer les modifications'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProfilePage;
|
||||
@@ -1,225 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Button, Card, Input, Select, Loading } from '@zen/core/shared/components';
|
||||
import { useToast } from '@zen/core/toast';
|
||||
|
||||
/**
|
||||
* User Edit Page Component
|
||||
* Page for editing an existing user (admin only)
|
||||
*/
|
||||
const UserEditPage = ({ userId, user }) => {
|
||||
const router = useRouter();
|
||||
const toast = useToast();
|
||||
|
||||
const [userData, setUserData] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
role: 'user',
|
||||
email_verified: 'false',
|
||||
});
|
||||
const [errors, setErrors] = useState({});
|
||||
|
||||
const roleOptions = [
|
||||
{ value: 'user', label: 'Utilisateur' },
|
||||
{ value: 'admin', label: 'Admin' }
|
||||
];
|
||||
|
||||
const emailVerifiedOptions = [
|
||||
{ value: 'false', label: 'Non vérifié' },
|
||||
{ value: 'true', label: 'Vérifié' }
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
loadUser();
|
||||
}, [userId]);
|
||||
|
||||
const loadUser = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch(`/zen/api/users/${userId}`, {
|
||||
credentials: 'include'
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (data.user) {
|
||||
setUserData(data.user);
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
name: data.user.name || '',
|
||||
role: data.user.role || 'user',
|
||||
email_verified: data.user.email_verified ? 'true' : 'false',
|
||||
}));
|
||||
} else {
|
||||
toast.error(data.message || 'Utilisateur introuvable');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading user:', error);
|
||||
toast.error('Impossible de charger l\'utilisateur');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputChange = (field, value) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
if (errors[field]) {
|
||||
setErrors(prev => ({ ...prev, [field]: null }));
|
||||
}
|
||||
};
|
||||
|
||||
const validateForm = () => {
|
||||
const newErrors = {};
|
||||
if (!formData.name || !formData.name.trim()) {
|
||||
newErrors.name = 'Le nom est requis';
|
||||
}
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!validateForm()) return;
|
||||
|
||||
try {
|
||||
setSaving(true);
|
||||
const response = await fetch(`/zen/api/users/${userId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
name: formData.name.trim(),
|
||||
role: formData.role,
|
||||
email_verified: formData.email_verified === 'true',
|
||||
})
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
toast.success('Utilisateur mis à jour avec succès');
|
||||
router.push('/admin/users');
|
||||
} else {
|
||||
toast.error(data.message || data.error || 'Impossible de mettre à jour l\'utilisateur');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating user:', error);
|
||||
toast.error('Impossible de mettre à jour l\'utilisateur');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-64 flex items-center justify-center">
|
||||
<Loading />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!userData) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4 sm:gap-6 lg:gap-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-lg sm:text-xl font-semibold text-neutral-900 dark:text-white">Modifier l'utilisateur</h1>
|
||||
<p className="mt-1 text-xs text-neutral-400">Utilisateur introuvable</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => router.push('/admin/users')}
|
||||
>
|
||||
← Retour aux utilisateurs
|
||||
</Button>
|
||||
</div>
|
||||
<Card>
|
||||
<div className="bg-red-500/10 border border-red-500/20 text-red-400 px-4 py-3 rounded-lg">
|
||||
<p className="font-medium">Utilisateur introuvable</p>
|
||||
<p className="text-sm mt-1">L'utilisateur que vous recherchez n'existe pas ou a été supprimé.</p>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 sm:gap-6 lg:gap-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-lg sm:text-xl font-semibold text-neutral-900 dark:text-white">Modifier l'utilisateur</h1>
|
||||
<p className="mt-1 text-xs text-neutral-400">{userData.email}</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => router.push('/admin/users')}
|
||||
>
|
||||
← Retour aux utilisateurs
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<Card>
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold text-neutral-900 dark:text-white">Informations de l'utilisateur</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Nom *"
|
||||
value={formData.name}
|
||||
onChange={(value) => handleInputChange('name', value)}
|
||||
placeholder="Nom de l'utilisateur"
|
||||
error={errors.name}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Email"
|
||||
value={userData.email}
|
||||
disabled
|
||||
/>
|
||||
|
||||
<Select
|
||||
label="Rôle"
|
||||
value={formData.role}
|
||||
onChange={(value) => handleInputChange('role', value)}
|
||||
options={roleOptions}
|
||||
/>
|
||||
|
||||
<Select
|
||||
label="Email vérifié"
|
||||
value={formData.email_verified}
|
||||
onChange={(value) => handleInputChange('email_verified', value)}
|
||||
options={emailVerifiedOptions}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => router.push('/admin/users')}
|
||||
disabled={saving}
|
||||
>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="success"
|
||||
loading={saving}
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? 'Enregistrement...' : 'Mettre à jour'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserEditPage;
|
||||
@@ -1,220 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Card, Table, StatusBadge, Pagination, Button } from '@zen/core/shared/components';
|
||||
import { PencilEdit01Icon } from '@zen/core/shared/icons';
|
||||
import { useToast } from '@zen/core/toast';
|
||||
|
||||
const UsersPageClient = () => {
|
||||
const router = useRouter();
|
||||
const toast = useToast();
|
||||
const [users, setUsers] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Pagination state
|
||||
const [pagination, setPagination] = useState({
|
||||
page: 1,
|
||||
limit: 20,
|
||||
total: 0,
|
||||
totalPages: 0
|
||||
});
|
||||
|
||||
// Sort state
|
||||
const [sortBy, setSortBy] = useState('created_at');
|
||||
const [sortOrder, setSortOrder] = useState('desc');
|
||||
|
||||
// Table columns configuration
|
||||
const columns = [
|
||||
{
|
||||
key: 'name',
|
||||
label: 'Nom',
|
||||
sortable: true,
|
||||
render: (user) => (
|
||||
<div>
|
||||
<div className="text-sm font-medium text-neutral-900 dark:text-white">{user.name}</div>
|
||||
<div className="text-xs text-neutral-500 dark:text-gray-400">ID: {user.id.slice(0, 8)}...</div>
|
||||
</div>
|
||||
),
|
||||
skeleton: {
|
||||
height: 'h-4', width: '60%',
|
||||
secondary: { height: 'h-3', width: '40%' }
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'email',
|
||||
label: 'Email',
|
||||
sortable: true,
|
||||
render: (user) => <div className="text-sm font-medium text-neutral-900 dark:text-white">{user.email}</div>,
|
||||
skeleton: {
|
||||
height: 'h-4',
|
||||
width: '60%',
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'role',
|
||||
label: 'Rôle',
|
||||
sortable: true,
|
||||
render: (user) => <StatusBadge status={user.role} />,
|
||||
skeleton: { height: 'h-6', width: '80px', className: 'rounded-full' }
|
||||
},
|
||||
{
|
||||
key: 'email_verified',
|
||||
label: 'Statut',
|
||||
sortable: true,
|
||||
render: (user) => <StatusBadge status={user.email_verified ? 'verified' : 'unverified'} />,
|
||||
skeleton: { height: 'h-6', width: '90px', className: 'rounded-full' }
|
||||
},
|
||||
{
|
||||
key: 'created_at',
|
||||
label: 'Créé le',
|
||||
sortable: true,
|
||||
render: (user) => (
|
||||
<span className="text-sm text-neutral-600 dark:text-gray-300">
|
||||
{formatDate(user.created_at)}
|
||||
</span>
|
||||
),
|
||||
skeleton: { height: 'h-4', width: '70%' }
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
label: '',
|
||||
sortable: false,
|
||||
noWrap: true,
|
||||
render: (user) => (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => router.push(`/admin/users/edit/${user.id}`)}
|
||||
icon={<PencilEdit01Icon className="w-4 h-4" />}
|
||||
>
|
||||
Modifier
|
||||
</Button>
|
||||
),
|
||||
skeleton: { height: 'h-8', width: '80px', className: 'rounded-lg' }
|
||||
}
|
||||
];
|
||||
|
||||
// Fetch users function
|
||||
const fetchUsers = async () => {
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const searchParams = new URLSearchParams({
|
||||
page: pagination.page.toString(),
|
||||
limit: pagination.limit.toString(),
|
||||
sortBy,
|
||||
sortOrder
|
||||
});
|
||||
|
||||
const response = await fetch(`/zen/api/users?${searchParams}`, {
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Error: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setUsers(data.users);
|
||||
setPagination(prev => ({
|
||||
...prev,
|
||||
total: data.pagination.total,
|
||||
totalPages: data.pagination.totalPages,
|
||||
page: data.pagination.page
|
||||
}));
|
||||
} catch (err) {
|
||||
toast.error(err.message || 'Impossible de charger les utilisateurs');
|
||||
console.error('Error fetching users:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Effect to fetch users when sort or pagination change
|
||||
useEffect(() => {
|
||||
fetchUsers();
|
||||
}, [sortBy, sortOrder, pagination.page, pagination.limit]);
|
||||
|
||||
// Handle pagination
|
||||
const handlePageChange = (newPage) => {
|
||||
setPagination(prev => ({ ...prev, page: newPage }));
|
||||
};
|
||||
|
||||
const handleLimitChange = (newLimit) => {
|
||||
setPagination(prev => ({
|
||||
...prev,
|
||||
limit: newLimit,
|
||||
page: 1 // Reset to first page when changing limit
|
||||
}));
|
||||
};
|
||||
|
||||
// Handle sorting
|
||||
const handleSort = (newSortBy) => {
|
||||
const newSortOrder = sortBy === newSortBy && sortOrder === 'asc' ? 'desc' : 'asc';
|
||||
setSortBy(newSortBy);
|
||||
setSortOrder(newSortOrder);
|
||||
};
|
||||
|
||||
// Format date helper
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return 'N/A';
|
||||
try {
|
||||
return new Date(dateString).toLocaleDateString('fr-FR', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
} catch {
|
||||
return 'Date invalide';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Users Table */}
|
||||
<Card variant="default" padding="none">
|
||||
<Table
|
||||
columns={columns}
|
||||
data={users}
|
||||
loading={loading}
|
||||
sortBy={sortBy}
|
||||
sortOrder={sortOrder}
|
||||
onSort={handleSort}
|
||||
emptyMessage="Aucun utilisateur trouvé"
|
||||
emptyDescription="La base de données est vide"
|
||||
/>
|
||||
|
||||
<Pagination
|
||||
currentPage={pagination.page}
|
||||
totalPages={pagination.totalPages}
|
||||
onPageChange={handlePageChange}
|
||||
onLimitChange={handleLimitChange}
|
||||
limit={pagination.limit}
|
||||
total={pagination.total}
|
||||
loading={loading}
|
||||
showPerPage={true}
|
||||
showStats={true}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const UsersPage = () => {
|
||||
return (
|
||||
<div className="flex flex-col gap-4 sm:gap-6 lg:gap-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-lg sm:text-xl font-semibold text-neutral-900 dark:text-white">Utilisateurs</h1>
|
||||
<p className="mt-1 text-xs text-neutral-400">Gérez les comptes utilisateurs</p>
|
||||
</div>
|
||||
</div>
|
||||
<UsersPageClient />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UsersPage;
|
||||
@@ -0,0 +1,258 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Badge,
|
||||
StatusBadge,
|
||||
Input,
|
||||
Select,
|
||||
Textarea,
|
||||
Switch,
|
||||
TagInput,
|
||||
StatCard,
|
||||
Loading,
|
||||
BlockEditor,
|
||||
} from '@zen/core/shared/components';
|
||||
import { UserCircle02Icon } from '@zen/core/shared/icons';
|
||||
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 }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<h3 className="text-[11px] font-semibold uppercase tracking-widest text-neutral-500 dark:text-neutral-400">{title}</h3>
|
||||
<div className="rounded-xl border border-neutral-200 dark:border-neutral-800 bg-neutral-50 dark:bg-neutral-900/50 p-6 flex flex-wrap gap-3 items-center">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ComponentsPage() {
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
<AdminHeader title="Composants" description="Catalogue visuel des composants partagés" />
|
||||
|
||||
<PreviewBlock title="Button — variants">
|
||||
<Button variant="primary" size="md">Primary</Button>
|
||||
<Button variant="secondary" size="md">Secondary</Button>
|
||||
<Button variant="success" size="md">Success</Button>
|
||||
<Button variant="danger" size="md">Danger</Button>
|
||||
<Button variant="warning" size="md">Warning</Button>
|
||||
<Button variant="ghost" size="md">Ghost</Button>
|
||||
<Button variant="fullghost" size="md">Full Ghost</Button>
|
||||
</PreviewBlock>
|
||||
|
||||
<PreviewBlock title="Button — tailles">
|
||||
<Button variant="primary" size="sm">Small</Button>
|
||||
<Button variant="primary" size="md">Medium</Button>
|
||||
<Button variant="primary" size="lg">Large</Button>
|
||||
</PreviewBlock>
|
||||
|
||||
<PreviewBlock title="Button — états">
|
||||
<Button variant="primary" disabled>Désactivé</Button>
|
||||
<Button variant="primary" loading>Chargement</Button>
|
||||
</PreviewBlock>
|
||||
|
||||
<PreviewBlock title="Badge — variants">
|
||||
<Badge variant="default">Default</Badge>
|
||||
<Badge variant="primary">Primary</Badge>
|
||||
<Badge variant="success">Success</Badge>
|
||||
<Badge variant="warning">Warning</Badge>
|
||||
<Badge variant="danger">Danger</Badge>
|
||||
<Badge variant="info">Info</Badge>
|
||||
<Badge variant="purple">Purple</Badge>
|
||||
<Badge variant="pink">Pink</Badge>
|
||||
<Badge variant="orange">Orange</Badge>
|
||||
</PreviewBlock>
|
||||
|
||||
<PreviewBlock title="Badge — pastilles (dot)">
|
||||
<Badge color="#6366f1" dot>Indigo</Badge>
|
||||
<Badge color="#f59e0b" dot>Ambre</Badge>
|
||||
<Badge color="#10b981" dot>Émeraude</Badge>
|
||||
<Badge color="#3b82f6" dot>Bleu</Badge>
|
||||
<Badge color="#ec4899" dot>Rose</Badge>
|
||||
<Badge color="#ef4444" dot>Rouge</Badge>
|
||||
<Badge color="#8b5cf6" dot>Violet</Badge>
|
||||
<Badge color="#14b8a6" dot>Teal</Badge>
|
||||
</PreviewBlock>
|
||||
|
||||
<PreviewBlock title="StatusBadge">
|
||||
<StatusBadge status="active" />
|
||||
<StatusBadge status="inactive" />
|
||||
<StatusBadge status="pending" />
|
||||
<StatusBadge status="verified" />
|
||||
<StatusBadge status="unverified" />
|
||||
<StatusBadge status="admin" />
|
||||
<StatusBadge status="user" />
|
||||
</PreviewBlock>
|
||||
|
||||
<PreviewBlock title="Card — variants">
|
||||
{['default', 'elevated', 'outline', 'success', 'info', 'warning', 'danger'].map(v => (
|
||||
<Card key={v} variant={v} padding="md" className="min-w-[140px]">
|
||||
<span className="text-sm font-medium text-black dark:text-white">{v}</span>
|
||||
</Card>
|
||||
))}
|
||||
</PreviewBlock>
|
||||
|
||||
<PreviewBlock title="StatCard">
|
||||
<StatCard
|
||||
title="Utilisateurs"
|
||||
value="1 234"
|
||||
change="+42 ce mois"
|
||||
changeType="increase"
|
||||
icon={UserCircle02Icon}
|
||||
color="text-blue-700"
|
||||
bgColor="bg-blue-700/10"
|
||||
className="w-56"
|
||||
/>
|
||||
<StatCard
|
||||
title="Revenus"
|
||||
value="8 400 $"
|
||||
change="-120 ce mois"
|
||||
changeType="decrease"
|
||||
icon={UserCircle02Icon}
|
||||
color="text-red-700"
|
||||
bgColor="bg-red-700/10"
|
||||
className="w-56"
|
||||
/>
|
||||
<StatCard
|
||||
title="Chargement"
|
||||
value="..."
|
||||
icon={UserCircle02Icon}
|
||||
color="text-neutral-400"
|
||||
bgColor="bg-neutral-400/10"
|
||||
loading
|
||||
className="w-56"
|
||||
/>
|
||||
</PreviewBlock>
|
||||
|
||||
<PreviewBlock title="Input">
|
||||
<div className="w-72 flex flex-col gap-3">
|
||||
<Input label="Champ normal" placeholder="Valeur..." value="" onChange={() => {}} />
|
||||
<Input label="Avec description" placeholder="Valeur..." value="" description="Texte d'aide sous le champ." onChange={() => {}} />
|
||||
<Input label="Avec erreur" placeholder="Valeur..." value="" error="Ce champ est invalide." onChange={() => {}} />
|
||||
<Input label="Désactivé" placeholder="Valeur..." value="Valeur fixe" disabled onChange={() => {}} />
|
||||
<Input label="Requis" placeholder="Valeur..." value="" required onChange={() => {}} />
|
||||
</div>
|
||||
</PreviewBlock>
|
||||
|
||||
<PreviewBlock title="Select">
|
||||
<div className="w-72 flex flex-col gap-3">
|
||||
<Select
|
||||
label="Sélection normale"
|
||||
value="option1"
|
||||
options={[{ value: 'option1', label: 'Option 1' }, { value: 'option2', label: 'Option 2' }]}
|
||||
onChange={() => {}}
|
||||
/>
|
||||
<Select
|
||||
label="Avec erreur"
|
||||
value=""
|
||||
options={[{ value: 'option1', label: 'Option 1' }]}
|
||||
error="Veuillez choisir une option."
|
||||
onChange={() => {}}
|
||||
/>
|
||||
<Select
|
||||
label="Désactivé"
|
||||
value="option1"
|
||||
options={[{ value: 'option1', label: 'Option 1' }]}
|
||||
disabled
|
||||
onChange={() => {}}
|
||||
/>
|
||||
</div>
|
||||
</PreviewBlock>
|
||||
|
||||
<PreviewBlock title="Textarea">
|
||||
<div className="w-72 flex flex-col gap-3">
|
||||
<Textarea label="Zone de texte" placeholder="Entrer du texte..." value="" rows={3} onChange={() => {}} />
|
||||
<Textarea label="Avec erreur" placeholder="..." value="" error="Ce champ est requis." rows={2} onChange={() => {}} />
|
||||
<Textarea label="Désactivé" value="Texte fixe" disabled rows={2} onChange={() => {}} />
|
||||
</div>
|
||||
</PreviewBlock>
|
||||
|
||||
<PreviewBlock title="Switch">
|
||||
<div className="w-72 flex flex-col gap-1 border border-neutral-200 dark:border-neutral-800 rounded-xl overflow-hidden">
|
||||
<Switch label="Désactivé" description="Ce switch est off" checked={false} onChange={() => {}} />
|
||||
<Switch label="Activé" description="Ce switch est on" checked={true} onChange={() => {}} />
|
||||
<Switch label="Désactivé (disabled)" checked={false} disabled onChange={() => {}} />
|
||||
</div>
|
||||
</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">
|
||||
<Loading />
|
||||
</PreviewBlock>
|
||||
|
||||
<PreviewBlock title="BlockEditor">
|
||||
<BlockEditorDemo />
|
||||
</PreviewBlock>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BlockEditorDemo() {
|
||||
const [blocks, setBlocks] = useState([
|
||||
{ id: 'demo-1', type: 'heading_1', content: [{ type: 'text', text: 'Bienvenue dans BlockEditor' }] },
|
||||
{ id: 'demo-2', type: 'paragraph', content: [
|
||||
{ type: 'text', text: "Tapez " },
|
||||
{ type: 'text', text: "'/'", marks: [{ type: 'code' }] },
|
||||
{ type: 'text', text: ' pour ouvrir le menu, ou ' },
|
||||
{ type: 'text', text: 'sélectionnez', marks: [{ type: 'bold' }] },
|
||||
{ type: 'text', text: ' pour ' },
|
||||
{ type: 'text', text: 'mettre en forme', marks: [{ type: 'italic' }, { type: 'color', color: 'blue' }] },
|
||||
{ type: 'text', text: '.' },
|
||||
] },
|
||||
{ id: 'demo-3', type: 'checklist', checked: true, content: [{ type: 'text', text: 'Format inline (gras, italique, couleur, lien)' }] },
|
||||
{ id: 'demo-4', type: 'checklist', checked: false, content: [{ type: 'text', text: 'Bloc image (URL uniquement en Phase 2)' }] },
|
||||
{ id: 'demo-5', type: 'bullet_item', content: [{ type: 'text', text: 'Glissez la poignée ⋮⋮ pour réordonner' }] },
|
||||
{ id: 'demo-6', 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
'use client';
|
||||
|
||||
import { registerPage } from '../registry.js';
|
||||
import ComponentsPage from './ComponentsPage.client.js';
|
||||
import IconsPage from './IconsPage.client.js';
|
||||
|
||||
function DevkitPage({ params, devkitEnabled }) {
|
||||
if (!devkitEnabled) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-24 text-neutral-400 dark:text-neutral-600 text-sm">
|
||||
DevKit désactivé. Définir <code className="mx-1 font-mono bg-neutral-100 dark:bg-neutral-800 px-1 rounded">ZEN_DEVKIT=true</code> pour activer.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const subPage = params?.[1] || 'components';
|
||||
|
||||
if (subPage === 'icons') return <IconsPage />;
|
||||
return <ComponentsPage />;
|
||||
}
|
||||
|
||||
export default DevkitPage;
|
||||
registerPage({ slug: 'devkit', title: 'DevKit', Component: DevkitPage });
|
||||
@@ -0,0 +1,151 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
import * as Icons from '@zen/core/shared/icons';
|
||||
import { useToast } from '@zen/core/toast';
|
||||
import AdminHeader from '../components/AdminHeader.js';
|
||||
|
||||
const ALL_ICONS = Object.entries(Icons);
|
||||
|
||||
export default function IconsPage() {
|
||||
const [query, setQuery] = useState('');
|
||||
const [selectedCategory, setSelectedCategory] = useState(null);
|
||||
const toast = useToast();
|
||||
|
||||
const categories = useMemo(() => {
|
||||
const map = new Map();
|
||||
for (const [, Icon] of ALL_ICONS) {
|
||||
const cat = Icon.category;
|
||||
if (!cat) continue;
|
||||
if (!map.has(cat)) map.set(cat, { count: 0, FirstIcon: Icon });
|
||||
const entry = map.get(cat);
|
||||
entry.count += 1;
|
||||
if (Icon.categoryIcon) entry.FirstIcon = Icon;
|
||||
}
|
||||
return Array.from(map.entries()).sort(([a], [b]) => a.localeCompare(b));
|
||||
}, []);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
let list = ALL_ICONS;
|
||||
if (selectedCategory) list = list.filter(([, Icon]) => Icon.category === selectedCategory);
|
||||
if (!query.trim()) return list;
|
||||
const q = query.trim().toLowerCase();
|
||||
return list.filter(([name, Icon]) =>
|
||||
name.toLowerCase().includes(q) ||
|
||||
Icon.keywords?.some(k => k.toLowerCase().includes(q))
|
||||
);
|
||||
}, [query, selectedCategory]);
|
||||
|
||||
const handleCopy = (name, e) => {
|
||||
if (e.shiftKey) {
|
||||
navigator.clipboard.writeText(`<${name} className="w-5 h-5" />`);
|
||||
toast.success(`JSX de ${name} copié`);
|
||||
} else {
|
||||
navigator.clipboard.writeText(name);
|
||||
toast.success(`${name} copié`);
|
||||
}
|
||||
};
|
||||
|
||||
const hasSidebar = categories.length > 0;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<AdminHeader
|
||||
title="Icônes"
|
||||
description={`${ALL_ICONS.length} icônes disponibles`}
|
||||
/>
|
||||
|
||||
<div className={`flex gap-4 items-start ${hasSidebar ? 'flex-col sm:flex-row' : ''}`}>
|
||||
<div className="flex-1 min-w-0 flex flex-col gap-6">
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
placeholder="Rechercher une icône..."
|
||||
className="w-full rounded-lg border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-4 py-2.5 text-sm text-neutral-900 dark:text-white placeholder-neutral-400 dark:placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-blue-700/40"
|
||||
/>
|
||||
{query && (
|
||||
<button
|
||||
onClick={() => setQuery('')}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-200 text-lg leading-none"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400 text-center py-12">
|
||||
Aucune icône trouvée pour “{query}”
|
||||
</p>
|
||||
) : (
|
||||
<div className={`grid gap-2 ${
|
||||
hasSidebar
|
||||
? 'grid-cols-4 sm:grid-cols-4 md:grid-cols-6 lg:grid-cols-8 xl:grid-cols-10 2xl:grid-cols-[repeat(13,minmax(0,1fr))]'
|
||||
: '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))]'
|
||||
}`}>
|
||||
{filtered.map(([name, IconComponent]) => (
|
||||
<button
|
||||
key={name}
|
||||
onClick={(e) => handleCopy(name, e)}
|
||||
title={`${name.replace('Icon', '')} · Shift: JSX`}
|
||||
className="aspect-square flex flex-col items-center justify-between 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 px-1 py-2 overflow-hidden"
|
||||
>
|
||||
<div className="flex-1 flex items-center justify-center w-full">
|
||||
<IconComponent className="w-7 h-7 text-black dark:text-white" />
|
||||
</div>
|
||||
<span className="shrink-0 text-[9px] text-neutral-500 dark:text-neutral-400 leading-none text-center truncate group-hover:text-neutral-600 dark:group-hover:text-neutral-300 w-full px-1">
|
||||
{name.replace('Icon', '')}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasSidebar && (
|
||||
<div className="w-full sm:w-[250px] sm:shrink-0 sm:sticky sm:top-4 rounded-lg border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 overflow-hidden order-first sm:order-none">
|
||||
<div className="px-3 py-2.5 border-b border-neutral-200 dark:border-neutral-800">
|
||||
<span className="text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wide">Catégories</span>
|
||||
</div>
|
||||
<div className="py-1 flex sm:flex-col flex-row flex-wrap sm:flex-nowrap gap-0">
|
||||
<button
|
||||
onClick={() => setSelectedCategory(null)}
|
||||
className={`cursor-pointer flex items-center justify-between px-3 py-1.5 text-sm rounded-md m-1 sm:mx-1 sm:my-0 sm:w-[calc(100%-8px)] transition-colors ${
|
||||
selectedCategory === null
|
||||
? 'bg-neutral-100 dark:bg-neutral-800 text-neutral-900 dark:text-white'
|
||||
: 'text-neutral-600 dark:text-neutral-400 hover:bg-neutral-100 dark:hover:bg-neutral-800 hover:text-neutral-900 dark:hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<span className="truncate">Tout</span>
|
||||
<span className="text-xs tabular-nums shrink-0 ml-2 text-neutral-400 dark:text-neutral-500">
|
||||
{ALL_ICONS.length}
|
||||
</span>
|
||||
</button>
|
||||
{categories.map(([cat, { count, FirstIcon }]) => (
|
||||
<button
|
||||
key={cat}
|
||||
onClick={() => setSelectedCategory(cat)}
|
||||
className={`cursor-pointer flex items-center justify-between px-3 py-1.5 text-sm rounded-md m-1 sm:mx-1 sm:my-0 sm:w-[calc(100%-8px)] transition-colors ${
|
||||
selectedCategory === cat
|
||||
? 'bg-neutral-100 dark:bg-neutral-800 text-neutral-900 dark:text-white'
|
||||
: 'text-neutral-600 dark:text-neutral-400 hover:bg-neutral-100 dark:hover:bg-neutral-800 hover:text-neutral-900 dark:hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<span className="flex items-center gap-2 min-w-0">
|
||||
<FirstIcon className="w-4 h-4 shrink-0 text-neutral-500 dark:text-neutral-400" />
|
||||
<span className="truncate">{cat}</span>
|
||||
</span>
|
||||
<span className="text-xs tabular-nums shrink-0 ml-2 text-neutral-400 dark:text-neutral-500">
|
||||
{count}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+28
-13
@@ -1,16 +1,31 @@
|
||||
/**
|
||||
* Zen Admin Module
|
||||
* Admin panel functionality with role-based access control
|
||||
* Zen Admin — barrel serveur (Next.js-free).
|
||||
*
|
||||
* - Navigation : buildNavigationSections.
|
||||
* - Registre d'extensions : registerWidget, registerWidgetFetcher, registerNavItem,
|
||||
* registerNavSection, registerPage (import une seule fois depuis le layout
|
||||
* racine de l'app consommatrice pour que les side effects s'exécutent).
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
// Middleware exports
|
||||
export { protectAdmin, isAdmin } from './middleware/protect.js';
|
||||
|
||||
// Component exports (for catch-all routes)
|
||||
export { AdminPagesClient, AdminPagesLayout } from './pages.js';
|
||||
|
||||
// NOTE: Server-only navigation builder is in '@zen/core/admin/navigation'
|
||||
// Do NOT import from this file to avoid bundling database code into client
|
||||
|
||||
// NOTE: Admin server actions are exported separately to avoid bundling issues
|
||||
// Import them from '@zen/core/admin/actions' instead
|
||||
export { buildNavigationSections, buildBottomNavItems, getNavItemBasePath, findActiveNavContext } from './navigation.js';
|
||||
export {
|
||||
registerWidget,
|
||||
registerWidgetFetcher,
|
||||
registerNavItem,
|
||||
registerNavSection,
|
||||
registerPage,
|
||||
collectWidgetData,
|
||||
getWidgets,
|
||||
getNavItems,
|
||||
getNavSections,
|
||||
getPage,
|
||||
getPages,
|
||||
} from './registry.js';
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
/**
|
||||
* Admin Route Protection Middleware
|
||||
* Utilities to protect admin routes and require admin role
|
||||
*/
|
||||
|
||||
import { getSession } from '@zen/core/features/auth/actions';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
/**
|
||||
* Protect an admin page - requires authentication and admin role
|
||||
* Use this in server components to require admin access
|
||||
*
|
||||
* @param {Object} options - Protection options
|
||||
* @param {string} options.redirectTo - Where to redirect if not authenticated (default: '/auth/login')
|
||||
* @param {string} options.forbiddenRedirect - Where to redirect if not admin (default: '/')
|
||||
* @returns {Promise<Object>} Session object with user data
|
||||
*
|
||||
* @example
|
||||
* // In a server component:
|
||||
* import { protectAdmin } from '@zen/core/features/admin';
|
||||
*
|
||||
* export default async function AdminPage() {
|
||||
* const session = await protectAdmin();
|
||||
* return <div>Welcome Admin, {session.user.name}!</div>;
|
||||
* }
|
||||
*/
|
||||
async function protectAdmin(options = {}) {
|
||||
const { redirectTo = '/auth/login', forbiddenRedirect = '/' } = options;
|
||||
|
||||
const session = await getSession();
|
||||
|
||||
if (!session) {
|
||||
redirect(redirectTo);
|
||||
}
|
||||
|
||||
if (session.user.role !== 'admin') {
|
||||
redirect(forbiddenRedirect);
|
||||
}
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user is admin
|
||||
* Use this when you want to check admin status without forcing a redirect
|
||||
*
|
||||
* @returns {Promise<boolean>} True if user is admin
|
||||
*
|
||||
* @example
|
||||
* import { isAdmin } from '@zen/core/features/admin';
|
||||
*
|
||||
* export default async function Page() {
|
||||
* const admin = await isAdmin();
|
||||
* return admin ? <div>Admin panel</div> : <div>Access denied</div>;
|
||||
* }
|
||||
*/
|
||||
async function isAdmin() {
|
||||
const session = await getSession();
|
||||
return session && session.user.role === 'admin';
|
||||
}
|
||||
|
||||
export {
|
||||
protectAdmin,
|
||||
isAdmin
|
||||
};
|
||||
@@ -0,0 +1,150 @@
|
||||
import {
|
||||
registerNavSection,
|
||||
registerNavItem,
|
||||
getNavSections,
|
||||
getNavItems,
|
||||
} from './registry.js';
|
||||
import { isDevkitEnabled } from '../../shared/lib/appConfig.js';
|
||||
import { PERMISSIONS } from '@zen/core/users/constants';
|
||||
// Side-effect : déclenche l'enregistrement nav du module Médias (gated par
|
||||
// ZEN_MEDIA en interne). Importé en haut du fichier pour que les side effects
|
||||
// s'exécutent lors du premier import du barrel admin.
|
||||
import '../media/navigation.js';
|
||||
|
||||
// Sections et items core — enregistrés à l'import de ce module.
|
||||
registerNavSection({ id: 'dashboard', title: 'Tableau de bord', icon: 'DashboardSquare03Icon', order: 10 });
|
||||
registerNavSection({ id: 'system', title: 'Utilisateurs', icon: 'UserMultiple02Icon', order: 800 });
|
||||
|
||||
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, permission: PERMISSIONS.USERS_VIEW });
|
||||
registerNavItem({ id: 'roles', label: 'Rôles', icon: 'Crown03Icon', href: '/admin/roles', sectionId: 'system', order: 20, permission: PERMISSIONS.ROLES_VIEW });
|
||||
registerNavItem({ id: 'settings', label: 'Paramètres', icon: 'Settings02Icon', href: '/admin/settings', position: 'bottom', order: 10 });
|
||||
|
||||
if (isDevkitEnabled()) {
|
||||
registerNavSection({ id: 'devkit', title: 'DevKit', icon: 'Wrench01Icon', order: 900 });
|
||||
registerNavItem({ id: 'devkit-components', label: 'Composants', icon: 'Layers01Icon', href: '/admin/devkit/components', sectionId: 'devkit', order: 10 });
|
||||
registerNavItem({ id: 'devkit-icons', label: 'Icônes', icon: 'Image01Icon', href: '/admin/devkit/icons', sectionId: 'devkit', order: 20 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Zone d'appartenance d'un nav item — toute URL préfixée par ce path est
|
||||
* considérée comme « sous » l'item, même si elle ne correspond pas à `href`.
|
||||
*
|
||||
* Auto-déduction : un href du type `/admin/posts/blogue/list` couvre aussi
|
||||
* `/edit/:id` et `/new` du même parent — on retire `/list` pour obtenir le
|
||||
* basePath. Un module peut surcharger via `registerNavItem({ basePath })`.
|
||||
*/
|
||||
export function getNavItemBasePath(item) {
|
||||
if (item.basePath) return item.basePath;
|
||||
if (item.href.endsWith('/list')) return item.href.replace(/\/list$/, '');
|
||||
return item.href;
|
||||
}
|
||||
|
||||
function matchLength(pathname, item) {
|
||||
if (pathname === item.href) return item.href.length;
|
||||
const basePath = getNavItemBasePath(item);
|
||||
if (pathname === basePath) return basePath.length;
|
||||
if (pathname.startsWith(basePath + '/')) return basePath.length;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sélectionne l'item « actif » pour un pathname donné parmi une liste d'items
|
||||
* (déjà filtrés par permission / position). Le match le plus long gagne :
|
||||
* sur `/admin/posts/blogue/taxonomies/categories`, l'item « Catégories »
|
||||
* (basePath complet) bat l'item « Posts » (basePath parent).
|
||||
*/
|
||||
function pickActiveItem(pathname, items) {
|
||||
let best = null;
|
||||
let bestLen = 0;
|
||||
for (const item of items) {
|
||||
const len = matchLength(pathname, item);
|
||||
if (len > bestLen) {
|
||||
best = item;
|
||||
bestLen = len;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build sections for AdminSidebar. Items are sérialisables (pas de composants),
|
||||
* icônes en chaînes résolues côté client.
|
||||
* @param {string} pathname
|
||||
* @param {string[]} [userPermissions] - Permissions de l'utilisateur connecté ; les items
|
||||
* avec un champ `permission` sont masqués si la permission n'est pas présente.
|
||||
*/
|
||||
export function buildNavigationSections(pathname, userPermissions = []) {
|
||||
const sections = getNavSections();
|
||||
const items = getNavItems().filter(item => {
|
||||
if (item.position === 'bottom') return false;
|
||||
if (item.permission && !userPermissions.includes(item.permission)) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const activeItem = pickActiveItem(pathname, items);
|
||||
|
||||
const bySection = new Map();
|
||||
for (const item of items) {
|
||||
const list = bySection.get(item.sectionId) || [];
|
||||
list.push({
|
||||
name: item.label,
|
||||
href: item.href,
|
||||
basePath: getNavItemBasePath(item),
|
||||
icon: item.icon,
|
||||
current: item === activeItem,
|
||||
});
|
||||
bySection.set(item.sectionId, list);
|
||||
}
|
||||
|
||||
return sections
|
||||
.filter(s => bySection.has(s.id))
|
||||
.map(s => ({ id: s.id, title: s.title, icon: s.icon, items: bySection.get(s.id) }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the list of bottom-pinned nav items for AdminSidebar.
|
||||
*/
|
||||
export function buildBottomNavItems(pathname) {
|
||||
const items = getNavItems()
|
||||
.filter(item => item.position === 'bottom')
|
||||
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
||||
|
||||
const activeItem = pickActiveItem(pathname, items);
|
||||
|
||||
return items.map(item => ({
|
||||
name: item.label,
|
||||
href: item.href,
|
||||
basePath: getNavItemBasePath(item),
|
||||
icon: item.icon,
|
||||
current: item === activeItem,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Localise dans `navigationSections` (forme retournée par `buildNavigationSections`)
|
||||
* l'item marqué comme actif et la section qui le contient. Utilisé par AdminTop
|
||||
* pour construire le fil d'Ariane à partir de la même source de vérité que la
|
||||
* sidebar — pas de double calcul de matching.
|
||||
*
|
||||
* Retourne `{ section, item, isExactPage, basePath }` ou `null`.
|
||||
* - `isExactPage` : on est sur l'URL exacte du nav item (page liste / page racine).
|
||||
* Permet de décider si l'item doit être cliquable dans le breadcrumb.
|
||||
* - `basePath` : zone d'appartenance, sert à isoler le « trail » d'action
|
||||
* (ex : `edit/1`, `new`) qui suit l'item dans le pathname.
|
||||
*/
|
||||
export function findActiveNavContext(pathname, navigationSections) {
|
||||
for (const section of navigationSections) {
|
||||
for (const item of section.items) {
|
||||
if (item.current) {
|
||||
return {
|
||||
section,
|
||||
item,
|
||||
isExactPage: pathname === item.href,
|
||||
basePath: item.basePath || item.href,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
/**
|
||||
* Admin Navigation Builder (Server-Only)
|
||||
*
|
||||
* IMPORTANT: Navigation data must be serializable (no functions/components).
|
||||
* Icons are passed as string names and resolved on the client.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Build complete navigation sections
|
||||
* @param {string} pathname - Current pathname
|
||||
* @returns {Array} Navigation sections (serializable, icons as strings)
|
||||
*/
|
||||
export function buildNavigationSections(pathname) {
|
||||
const coreNavigation = [
|
||||
{
|
||||
id: 'Dashboard',
|
||||
title: 'Tableau de bord',
|
||||
icon: 'DashboardSquare03Icon',
|
||||
items: [
|
||||
{
|
||||
name: 'Tableau de bord',
|
||||
href: '/admin/dashboard',
|
||||
icon: 'DashboardSquare03Icon',
|
||||
current: pathname === '/admin/dashboard'
|
||||
},
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
const systemNavigation = [
|
||||
{
|
||||
id: 'users',
|
||||
title: 'Utilisateurs',
|
||||
icon: 'UserMultiple02Icon',
|
||||
items: [
|
||||
{
|
||||
name: 'Utilisateurs',
|
||||
href: '/admin/users',
|
||||
icon: 'UserMultiple02Icon',
|
||||
current: pathname.startsWith('/admin/users')
|
||||
},
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
return [...coreNavigation, ...systemNavigation];
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
/**
|
||||
* Admin Page - Server Component Wrapper for Next.js App Router
|
||||
*
|
||||
* Re-export this in your app/admin/[...admin]/page.js:
|
||||
* export { default } from '@zen/core/features/admin/page';
|
||||
*/
|
||||
|
||||
import { AdminPagesLayout, AdminPagesClient } from '@zen/core/features/admin/pages';
|
||||
import { protectAdmin } from '@zen/core/features/admin';
|
||||
import { buildNavigationSections } from '@zen/core/features/admin/navigation';
|
||||
import { getDashboardStats } from '@zen/core/features/admin/actions';
|
||||
import { logoutAction } from '@zen/core/features/auth/actions';
|
||||
import { getAppName } from '@zen/core';
|
||||
|
||||
export default async function AdminPage({ params }) {
|
||||
const resolvedParams = await params;
|
||||
const session = await protectAdmin();
|
||||
const appName = getAppName();
|
||||
|
||||
const statsResult = await getDashboardStats();
|
||||
const dashboardStats = statsResult.success ? statsResult.stats : null;
|
||||
|
||||
const navigationSections = buildNavigationSections('/');
|
||||
|
||||
return (
|
||||
<AdminPagesLayout
|
||||
user={session.user}
|
||||
onLogout={logoutAction}
|
||||
appName={appName}
|
||||
navigationSections={navigationSections}
|
||||
>
|
||||
<AdminPagesClient
|
||||
params={resolvedParams}
|
||||
user={session.user}
|
||||
dashboardStats={dashboardStats}
|
||||
/>
|
||||
</AdminPagesLayout>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* Admin Pages Export for Next.js App Router
|
||||
*
|
||||
* This exports the admin client components.
|
||||
* Users must create their own server component wrapper that uses protectAdmin.
|
||||
*/
|
||||
|
||||
export { default as AdminPagesClient } from './components/AdminPages.js';
|
||||
export { default as AdminPagesLayout } from './components/AdminPagesLayout.js';
|
||||
@@ -0,0 +1,87 @@
|
||||
'use client';
|
||||
|
||||
import { registerPage } from '../registry.js';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { Card } from '@zen/core/shared/components';
|
||||
|
||||
const ConfirmEmailChangePage = () => {
|
||||
const searchParams = useSearchParams();
|
||||
const token = searchParams.get('token');
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [success, setSuccess] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const hasConfirmedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
setError('Lien de confirmation invalide.');
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
if (hasConfirmedRef.current) return;
|
||||
hasConfirmedRef.current = true;
|
||||
|
||||
fetch(`/zen/api/users/email/confirm?token=${encodeURIComponent(token)}`, { credentials: 'include' })
|
||||
.then(res => res.json().then(data => ({ ok: res.ok, data })))
|
||||
.then(({ ok, data }) => {
|
||||
if (ok && data.success) {
|
||||
setSuccess('Votre adresse courriel a été mise à jour avec succès.');
|
||||
setTimeout(() => { window.location.href = '/admin/profile'; }, 3000);
|
||||
} else {
|
||||
setError(data.error || data.message || 'Lien de confirmation invalide ou expiré.');
|
||||
}
|
||||
})
|
||||
.catch(() => setError('Une erreur inattendue est survenue.'))
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [token]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-64">
|
||||
<Card variant="default" padding="md" spacing="none" className="w-full max-w-md">
|
||||
<div className="text-center mb-6">
|
||||
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white mb-2">
|
||||
Confirmation du courriel
|
||||
</h1>
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-400">
|
||||
Validation de votre nouvelle adresse courriel...
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isLoading && (
|
||||
<div className="flex flex-col items-center py-10">
|
||||
<div className="w-12 h-12 border-4 border-neutral-300 border-t-neutral-900 rounded-full animate-spin dark:border-neutral-700/50 dark:border-t-white" />
|
||||
<p className="mt-5 text-neutral-600 dark:text-neutral-400 text-sm">Confirmation en cours...</p>
|
||||
</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" />
|
||||
<span className="text-xs text-green-700 dark:text-green-400">{success}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-center text-neutral-600 dark:text-neutral-400 text-sm">
|
||||
Redirection vers votre profil...
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<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" />
|
||||
<span className="text-xs text-red-700 dark:text-red-400">{error}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConfirmEmailChangePage;
|
||||
|
||||
registerPage({ slug: 'confirm-email-change', title: 'Confirmation du courriel', Component: ConfirmEmailChangePage });
|
||||
@@ -0,0 +1,23 @@
|
||||
'use client';
|
||||
|
||||
import { getWidgets, registerPage } from '../registry.js';
|
||||
import AdminHeader from '../components/AdminHeader.js';
|
||||
|
||||
export default function DashboardPage({ user, stats }) {
|
||||
const loading = stats === null || stats === undefined;
|
||||
const permissions = user?.permissions ?? [];
|
||||
const widgets = getWidgets().filter(w => !w.permission || permissions.includes(w.permission));
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 sm:gap-6 lg:gap-8">
|
||||
<AdminHeader title="Tableau de bord" description="Vue d'ensemble de votre application" />
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 gap-4 sm:gap-6">
|
||||
{widgets.map(({ id, Component }) => (
|
||||
<Component key={id} data={loading ? null : (stats[id] ?? null)} loading={loading} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
registerPage({ slug: 'dashboard', title: 'Tableau de bord', Component: DashboardPage });
|
||||
@@ -0,0 +1,524 @@
|
||||
'use client';
|
||||
|
||||
import { registerPage } from '../registry.js';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
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 AdminHeader from '../components/AdminHeader.js';
|
||||
|
||||
const TABS = [
|
||||
{ id: 'informations', label: 'Informations' },
|
||||
{ id: 'photo', label: 'Photo de profil' },
|
||||
{ id: 'securite', label: 'Sécurité' },
|
||||
{ id: 'sessions', label: 'Sessions' },
|
||||
];
|
||||
|
||||
const ProfilePage = ({ user: initialUser }) => {
|
||||
const toast = useToast();
|
||||
const fileInputRef = useRef(null);
|
||||
const [activeTab, setActiveTab] = useState('informations');
|
||||
const [user, setUser] = useState(initialUser);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [uploadingImage, setUploadingImage] = useState(false);
|
||||
const [formData, setFormData] = useState({ name: initialUser?.name || '' });
|
||||
|
||||
const [emailModalOpen, setEmailModalOpen] = useState(false);
|
||||
const [emailFormData, setEmailFormData] = useState({ newEmail: '', password: '' });
|
||||
const [emailLoading, setEmailLoading] = useState(false);
|
||||
const [pendingEmailMessage, setPendingEmailMessage] = useState('');
|
||||
|
||||
const [passwordForm, setPasswordForm] = useState({ currentPassword: '', newPassword: '', confirmPassword: '' });
|
||||
const [passwordLoading, setPasswordLoading] = useState(false);
|
||||
|
||||
const [sessions, setSessions] = useState([]);
|
||||
const [sessionsLoading, setSessionsLoading] = useState(false);
|
||||
const [currentSessionId, setCurrentSessionId] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialUser) setFormData({ name: initialUser.name || '' });
|
||||
}, [initialUser]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab !== 'sessions') return;
|
||||
setSessionsLoading(true);
|
||||
fetch('/zen/api/users/profile/sessions', { credentials: 'include' })
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.sessions) {
|
||||
setSessions(data.sessions);
|
||||
setCurrentSessionId(data.currentSessionId);
|
||||
}
|
||||
})
|
||||
.catch(() => toast.error('Impossible de charger les sessions'))
|
||||
.finally(() => setSessionsLoading(false));
|
||||
}, [activeTab]);
|
||||
|
||||
const handleRevokeSession = async (sessionId) => {
|
||||
try {
|
||||
const response = await fetch(`/zen/api/users/profile/sessions/${sessionId}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok || !data.success) throw new Error(data.error || 'Impossible de révoquer la session');
|
||||
if (data.isCurrent) {
|
||||
window.location.href = '/admin/login';
|
||||
} else {
|
||||
setSessions(prev => prev.filter(s => s.id !== sessionId));
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(error.message || 'Impossible de révoquer la session');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRevokeAllSessions = async () => {
|
||||
if (!confirm('Révoquer toutes les sessions ? Vous serez déconnecté de tous les appareils.')) return;
|
||||
try {
|
||||
const response = await fetch('/zen/api/users/profile/sessions', {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok || !data.success) throw new Error(data.error || 'Impossible de révoquer les sessions');
|
||||
window.location.href = '/admin/login';
|
||||
} catch (error) {
|
||||
toast.error(error.message || 'Impossible de révoquer les sessions');
|
||||
}
|
||||
};
|
||||
|
||||
const hasChanges = formData.name !== user?.name;
|
||||
|
||||
const validatePassword = (password) => {
|
||||
if (!password || password.length < 8) return 'Le mot de passe doit contenir au moins 8 caractères';
|
||||
if (password.length > 128) return 'Le mot de passe doit contenir 128 caractères ou moins';
|
||||
if (!/[A-Z]/.test(password)) return 'Le mot de passe doit contenir au moins une majuscule';
|
||||
if (!/[a-z]/.test(password)) return 'Le mot de passe doit contenir au moins une minuscule';
|
||||
if (!/\d/.test(password)) return 'Le mot de passe doit contenir au moins un chiffre';
|
||||
return null;
|
||||
};
|
||||
|
||||
const handlePasswordSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!passwordForm.currentPassword) { toast.error('Le mot de passe actuel est requis'); return; }
|
||||
const pwdError = validatePassword(passwordForm.newPassword);
|
||||
if (pwdError) { toast.error(pwdError); return; }
|
||||
if (passwordForm.newPassword !== passwordForm.confirmPassword) { toast.error('Les mots de passe ne correspondent pas'); return; }
|
||||
setPasswordLoading(true);
|
||||
try {
|
||||
const response = await fetch('/zen/api/users/profile/password', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ currentPassword: passwordForm.currentPassword, newPassword: passwordForm.newPassword }),
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok || !data.success) throw new Error(data.error || data.message || 'Échec de la mise à jour du mot de passe');
|
||||
toast.success(data.message);
|
||||
setPasswordForm({ currentPassword: '', newPassword: '', confirmPassword: '' });
|
||||
} catch (error) {
|
||||
toast.error(error.message || 'Échec de la mise à jour du mot de passe');
|
||||
} finally {
|
||||
setPasswordLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEmailSubmit = async () => {
|
||||
if (!emailFormData.newEmail.trim()) {
|
||||
toast.error('Le nouveau courriel est requis');
|
||||
return;
|
||||
}
|
||||
if (!emailFormData.password) {
|
||||
toast.error('Le mot de passe est requis');
|
||||
return;
|
||||
}
|
||||
setEmailLoading(true);
|
||||
try {
|
||||
const response = await fetch('/zen/api/users/profile/email', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ newEmail: emailFormData.newEmail.trim(), password: emailFormData.password }),
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok || !data.success) throw new Error(data.error || data.message || 'Échec de la demande de changement de courriel');
|
||||
toast.success(data.message);
|
||||
setPendingEmailMessage(data.message);
|
||||
setEmailModalOpen(false);
|
||||
setEmailFormData({ newEmail: '', password: '' });
|
||||
} catch (error) {
|
||||
toast.error(error.message || 'Échec de la demande de changement de courriel');
|
||||
} finally {
|
||||
setEmailLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!formData.name.trim()) {
|
||||
toast.error('Le nom est requis');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch('/zen/api/users/profile', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ name: formData.name.trim() }),
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok || !data.success) throw new Error(data.error || 'Échec de la mise à jour du profil');
|
||||
setUser(data.user);
|
||||
toast.success('Profil mis à jour avec succès');
|
||||
setTimeout(() => window.location.reload(), 1000);
|
||||
} catch (error) {
|
||||
toast.error(error.message || 'Échec de la mise à jour du profil');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImageSelect = async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
if (!file.type.startsWith('image/')) {
|
||||
toast.error('Veuillez sélectionner un fichier image');
|
||||
return;
|
||||
}
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
toast.error("L'image doit faire moins de 5MB");
|
||||
return;
|
||||
}
|
||||
setUploadingImage(true);
|
||||
try {
|
||||
const body = new FormData();
|
||||
body.append('file', file);
|
||||
const response = await fetch('/zen/api/users/profile/picture', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body,
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok || !data.success) throw new Error(data.message || "Échec du téléchargement de l'image");
|
||||
setUser(data.user);
|
||||
toast.success('Photo de profil mise à jour avec succès');
|
||||
} catch (error) {
|
||||
toast.error(error.message || "Échec du téléchargement de l'image");
|
||||
} finally {
|
||||
setUploadingImage(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveImage = async () => {
|
||||
if (!user?.image) return;
|
||||
setUploadingImage(true);
|
||||
try {
|
||||
const response = await fetch('/zen/api/users/profile/picture', {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok || !data.success) throw new Error(data.message || "Échec de la suppression de l'image");
|
||||
setUser(data.user);
|
||||
toast.success('Photo de profil supprimée avec succès');
|
||||
} catch (error) {
|
||||
toast.error(error.message || "Échec de la suppression de l'image");
|
||||
} finally {
|
||||
setUploadingImage(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-4 sm:gap-6 lg:gap-8">
|
||||
<AdminHeader title="Mon profil" description="Gérez les informations de votre compte" />
|
||||
|
||||
<div className="flex flex-col gap-6 items-start">
|
||||
<TabNav tabs={TABS} activeTab={activeTab} onTabChange={setActiveTab} />
|
||||
|
||||
{activeTab === 'informations' && (
|
||||
<Card
|
||||
title="Informations personnelles"
|
||||
className="w-full lg:max-w-4/5"
|
||||
footer={
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => setFormData({ name: user?.name || '' })}
|
||||
disabled={loading || !hasChanges}
|
||||
>
|
||||
Réinitialiser
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
disabled={loading || !hasChanges}
|
||||
loading={loading}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
Enregistrer les modifications
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Nom complet"
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(value) => setFormData(prev => ({ ...prev, name: value }))}
|
||||
placeholder="Entrez votre nom complet"
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Input
|
||||
label="Courriel"
|
||||
type="email"
|
||||
value={user?.email || ''}
|
||||
disabled
|
||||
readOnly
|
||||
description={pendingEmailMessage || undefined}
|
||||
/>
|
||||
{!pendingEmailMessage && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEmailModalOpen(true)}
|
||||
className="text-xs text-neutral-500 hover:text-neutral-800 dark:hover:text-neutral-200 underline self-start transition-colors"
|
||||
>
|
||||
Modifier le courriel
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<Input
|
||||
label="Compte créé"
|
||||
type="text"
|
||||
value={user?.created_at ? new Date(user.created_at).toLocaleDateString('fr-FR', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
}) : 'N/D'}
|
||||
disabled
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{activeTab === 'securite' && (
|
||||
<Card
|
||||
title="Changer le mot de passe"
|
||||
className="w-full lg:max-w-4/5"
|
||||
footer={
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => setPasswordForm({ currentPassword: '', newPassword: '', confirmPassword: '' })}
|
||||
disabled={passwordLoading}
|
||||
>
|
||||
Réinitialiser
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
form="password-form"
|
||||
variant="primary"
|
||||
disabled={passwordLoading}
|
||||
loading={passwordLoading}
|
||||
>
|
||||
Enregistrer le mot de passe
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<form id="password-form" onSubmit={handlePasswordSubmit} className="flex flex-col gap-4">
|
||||
<Input
|
||||
label="Mot de passe actuel"
|
||||
type="password"
|
||||
value={passwordForm.currentPassword}
|
||||
onChange={(value) => setPasswordForm(prev => ({ ...prev, currentPassword: value }))}
|
||||
placeholder="••••••••"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
disabled={passwordLoading}
|
||||
/>
|
||||
<div>
|
||||
<Input
|
||||
label="Nouveau mot de passe"
|
||||
type="password"
|
||||
value={passwordForm.newPassword}
|
||||
onChange={(value) => setPasswordForm(prev => ({ ...prev, newPassword: value }))}
|
||||
placeholder="••••••••"
|
||||
autoComplete="new-password"
|
||||
minLength="8"
|
||||
maxLength="128"
|
||||
required
|
||||
disabled={passwordLoading}
|
||||
/>
|
||||
<PasswordStrengthIndicator password={passwordForm.newPassword} showRequirements={true} />
|
||||
</div>
|
||||
<Input
|
||||
label="Confirmer le nouveau mot de passe"
|
||||
type="password"
|
||||
value={passwordForm.confirmPassword}
|
||||
onChange={(value) => setPasswordForm(prev => ({ ...prev, confirmPassword: value }))}
|
||||
placeholder="••••••••"
|
||||
autoComplete="new-password"
|
||||
minLength="8"
|
||||
maxLength="128"
|
||||
required
|
||||
disabled={passwordLoading}
|
||||
/>
|
||||
</form>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{activeTab === 'sessions' && (
|
||||
<Card
|
||||
title="Sessions actives"
|
||||
className="w-full lg:max-w-4/5"
|
||||
footer={
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="danger"
|
||||
onClick={handleRevokeAllSessions}
|
||||
disabled={sessionsLoading || sessions.length === 0}
|
||||
>
|
||||
Révoquer toutes les sessions
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{sessionsLoading ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<div className="w-6 h-6 border-2 border-neutral-300 dark:border-neutral-700 border-t-neutral-900 dark:border-t-neutral-100 rounded-full animate-spin" />
|
||||
</div>
|
||||
) : sessions.length === 0 ? (
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400 py-4 text-center">Aucune session active</p>
|
||||
) : (
|
||||
<div className="flex flex-col divide-y divide-neutral-100 dark:divide-neutral-800">
|
||||
{sessions.map(session => (
|
||||
<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">
|
||||
{session.device === 'mobile' ? (
|
||||
<SmartPhone01Icon className="w-8 h-8 text-neutral-500 dark:text-neutral-400 shrink-0" />
|
||||
) : (
|
||||
<ComputerIcon className="w-8 h-8 text-neutral-500 dark:text-neutral-400 shrink-0" />
|
||||
)}
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm font-medium text-neutral-900 dark:text-neutral-100">
|
||||
{session.browser} · {session.os}
|
||||
</span>
|
||||
{session.id === currentSessionId && (
|
||||
<span className="text-xs bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400 px-2 py-0.5 rounded-full font-medium">
|
||||
Session actuelle
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{session.ip_address || 'IP inconnue'} · {new Date(session.created_at).toLocaleDateString('fr-FR', { year: 'numeric', month: 'long', day: 'numeric' })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={() => handleRevokeSession(session.id)}
|
||||
>
|
||||
Révoquer
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{activeTab === 'photo' && (
|
||||
<Card title="Photo de profil" className="w-full lg:max-w-4/5">
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400">
|
||||
Téléchargez une nouvelle photo de profil. Taille max 5MB.
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-4 sm:gap-6">
|
||||
<div className="relative shrink-0">
|
||||
<UserAvatar user={user} size="xl" />
|
||||
{uploadingImage && (
|
||||
<div className="absolute inset-0 bg-black/50 rounded-full flex items-center justify-center">
|
||||
<div className="w-8 h-8 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleImageSelect}
|
||||
className="hidden"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploadingImage}
|
||||
>
|
||||
{uploadingImage ? 'Téléchargement...' : 'Télécharger une image'}
|
||||
</Button>
|
||||
{user?.image && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="danger"
|
||||
onClick={handleRemoveImage}
|
||||
disabled={uploadingImage}
|
||||
>
|
||||
Supprimer
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Modal
|
||||
isOpen={emailModalOpen}
|
||||
onClose={() => { setEmailModalOpen(false); setEmailFormData({ newEmail: '', password: '' }); }}
|
||||
title="Modifier le courriel"
|
||||
onSubmit={handleEmailSubmit}
|
||||
submitLabel="Envoyer la confirmation"
|
||||
loading={emailLoading}
|
||||
size="sm"
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<Input
|
||||
label="Nouveau courriel"
|
||||
type="email"
|
||||
value={emailFormData.newEmail}
|
||||
onChange={(value) => setEmailFormData(prev => ({ ...prev, newEmail: value }))}
|
||||
placeholder="nouvelle@adresse.com"
|
||||
required
|
||||
disabled={emailLoading}
|
||||
/>
|
||||
<Input
|
||||
label="Mot de passe actuel"
|
||||
type="password"
|
||||
value={emailFormData.password}
|
||||
onChange={(value) => setEmailFormData(prev => ({ ...prev, password: value }))}
|
||||
placeholder="Votre mot de passe"
|
||||
required
|
||||
disabled={emailLoading}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProfilePage;
|
||||
|
||||
registerPage({ slug: 'profile', title: 'Mon profil', Component: ProfilePage });
|
||||
@@ -0,0 +1,201 @@
|
||||
'use client';
|
||||
|
||||
import { registerPage } from '../registry.js';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Card, Table, Button, Badge } from '@zen/core/shared/components';
|
||||
import { PencilEdit01Icon, Cancel01Icon } from '@zen/core/shared/icons';
|
||||
import { useToast } from '@zen/core/toast';
|
||||
import AdminHeader from '../components/AdminHeader.js';
|
||||
import RoleEditModal from '../components/RoleEditModal.client.js';
|
||||
|
||||
const RolesPageClient = ({ canManage }) => {
|
||||
const toast = useToast();
|
||||
const [roles, setRoles] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editingRoleId, setEditingRoleId] = useState(null);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
|
||||
const openEdit = (roleId) => {
|
||||
setEditingRoleId(roleId);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
setModalOpen(false);
|
||||
setEditingRoleId(null);
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: 'name',
|
||||
label: 'Rôle',
|
||||
sortable: false,
|
||||
render: (role) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="inline-block w-3 h-3 rounded-full flex-shrink-0"
|
||||
style={{ backgroundColor: role.color || '#6b7280' }}
|
||||
/>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-neutral-900 dark:text-white">
|
||||
{role.name}
|
||||
</div>
|
||||
{role.description && (
|
||||
<div className="text-xs text-neutral-500 dark:text-gray-400">{role.description}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
skeleton: { height: 'h-4', width: '60%' },
|
||||
},
|
||||
{
|
||||
key: 'permission_count',
|
||||
label: 'Permissions',
|
||||
sortable: false,
|
||||
render: (role) => (
|
||||
<span className="text-sm text-neutral-600 dark:text-gray-300">{role.permission_count}</span>
|
||||
),
|
||||
skeleton: { height: 'h-4', width: '40px' },
|
||||
},
|
||||
{
|
||||
key: 'user_count',
|
||||
label: 'Utilisateurs',
|
||||
sortable: false,
|
||||
render: (role) => (
|
||||
<span className="text-sm text-neutral-600 dark:text-gray-300">{role.user_count}</span>
|
||||
),
|
||||
skeleton: { height: 'h-4', width: '40px' },
|
||||
},
|
||||
{
|
||||
key: 'is_system',
|
||||
label: 'Système',
|
||||
sortable: false,
|
||||
render: (role) => role.is_system ? <Badge variant="default" size="sm">système</Badge> : null,
|
||||
skeleton: { height: 'h-4', width: '60px' },
|
||||
},
|
||||
...(canManage ? [{
|
||||
key: 'actions',
|
||||
label: '',
|
||||
sortable: false,
|
||||
noWrap: true,
|
||||
align: 'right',
|
||||
render: (role) => (
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => openEdit(role.id)}
|
||||
icon={PencilEdit01Icon}
|
||||
>
|
||||
Modifier
|
||||
</Button>
|
||||
{!role.is_system && (
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={() => handleDelete(role)}
|
||||
icon={Cancel01Icon}
|
||||
>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
skeleton: { height: 'h-8', width: '80px', className: 'rounded-lg' },
|
||||
}] : []),
|
||||
];
|
||||
|
||||
const fetchRoles = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch('/zen/api/roles', { credentials: 'include' });
|
||||
if (!response.ok) throw new Error(`Error: ${response.status}`);
|
||||
const data = await response.json();
|
||||
setRoles(data.roles);
|
||||
} catch (err) {
|
||||
toast.error(err.message || 'Impossible de charger les rôles');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (role) => {
|
||||
if (!confirm(`Supprimer le rôle "${role.name}" ?`)) return;
|
||||
try {
|
||||
const response = await fetch(`/zen/api/roles/${role.id}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok) {
|
||||
toast.error(data.message || 'Impossible de supprimer ce rôle');
|
||||
return;
|
||||
}
|
||||
toast.success('Rôle supprimé');
|
||||
fetchRoles();
|
||||
} catch {
|
||||
toast.error('Impossible de supprimer ce rôle');
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchRoles();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card variant="default" padding="none">
|
||||
<Table
|
||||
columns={columns}
|
||||
data={roles}
|
||||
loading={loading}
|
||||
emptyMessage="Aucun rôle trouvé"
|
||||
emptyDescription="Créez un rôle pour commencer"
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<RoleEditModal
|
||||
roleId={editingRoleId}
|
||||
isOpen={modalOpen}
|
||||
onClose={closeModal}
|
||||
onSaved={fetchRoles}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const RolesPage = ({ user }) => {
|
||||
const canManage = user?.permissions?.includes('roles.manage');
|
||||
return (
|
||||
<div className="flex flex-col gap-4 sm:gap-6 lg:gap-8">
|
||||
<RolesPageHeader canManage={canManage} />
|
||||
<RolesPageClient canManage={canManage} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const RolesPageHeader = ({ canManage }) => {
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AdminHeader
|
||||
title="Rôles"
|
||||
description="Gérez les rôles et leurs permissions"
|
||||
action={canManage && (
|
||||
<Button variant="primary" onClick={() => setModalOpen(true)}>
|
||||
Nouveau rôle
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
{canManage && (
|
||||
<RoleEditModal
|
||||
roleId="new"
|
||||
isOpen={modalOpen}
|
||||
onClose={() => setModalOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default RolesPage;
|
||||
|
||||
registerPage({ slug: 'roles', title: 'Rôles', Component: RolesPage });
|
||||
@@ -0,0 +1,92 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { registerPage } from '../registry.js';
|
||||
import AdminHeader from '../components/AdminHeader.js';
|
||||
import { Card, Input, Select, TabNav } from '@zen/core/shared/components';
|
||||
import { applyTheme, getStoredTheme } from '@zen/core/themes';
|
||||
|
||||
const TABS = [
|
||||
{ id: 'general', label: 'Général' },
|
||||
{ id: 'appearance', label: 'Apparence' },
|
||||
];
|
||||
|
||||
const THEME_OPTIONS = [
|
||||
{ value: 'light', label: 'Mode clair' },
|
||||
{ value: 'dark', label: 'Mode sombre' },
|
||||
{ value: 'auto', label: 'Thème système' },
|
||||
];
|
||||
|
||||
const SettingsPage = ({ appConfig = {} }) => {
|
||||
const [activeTab, setActiveTab] = useState('general');
|
||||
const [theme, setTheme] = useState('auto');
|
||||
|
||||
useEffect(() => {
|
||||
setTheme(getStoredTheme());
|
||||
}, []);
|
||||
|
||||
const handleThemeChange = (value) => {
|
||||
setTheme(value);
|
||||
applyTheme(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 sm:gap-6 lg:gap-8">
|
||||
<AdminHeader title="Paramètres" description="Configuration de votre espace ZEN" />
|
||||
|
||||
<div className="flex flex-col gap-6 items-start">
|
||||
<TabNav tabs={TABS} activeTab={activeTab} onTabChange={setActiveTab} />
|
||||
|
||||
{activeTab === 'general' && (
|
||||
<Card title="Informations générales" className='w-full lg:max-w-4/5'>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Nom du site"
|
||||
value={appConfig.name || ''}
|
||||
readOnly
|
||||
disabled
|
||||
/>
|
||||
<Input
|
||||
label="URL du site"
|
||||
value={appConfig.siteUrl || ''}
|
||||
readOnly
|
||||
disabled
|
||||
description="URL publique de votre site"
|
||||
/>
|
||||
<Input
|
||||
label="Fuseau horaire"
|
||||
value={appConfig.timezone || ''}
|
||||
readOnly
|
||||
disabled
|
||||
/>
|
||||
<Input
|
||||
label="Format de date"
|
||||
value={appConfig.dateFormat || ''}
|
||||
readOnly
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{activeTab === 'appearance' && (
|
||||
<Card title="Thème" className='w-full lg:max-w-4/5'>
|
||||
<div className="max-w-xs">
|
||||
<Select
|
||||
label="Thème de l'interface"
|
||||
value={theme}
|
||||
onChange={handleThemeChange}
|
||||
options={THEME_OPTIONS}
|
||||
description="S'applique immédiatement et persiste entre les sessions"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsPage;
|
||||
|
||||
registerPage({ slug: 'settings', title: 'Paramètres', Component: SettingsPage });
|
||||
@@ -0,0 +1,204 @@
|
||||
'use client';
|
||||
|
||||
import { registerPage } from '../registry.js';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Card, Table, Badge, StatusBadge, Button, UserAvatar, RelativeDate } from '@zen/core/shared/components';
|
||||
import { PencilEdit01Icon } from '@zen/core/shared/icons';
|
||||
import { useToast } from '@zen/core/toast';
|
||||
import AdminHeader from '../components/AdminHeader.js';
|
||||
import UserEditModal from '../components/UserEditModal.client.js';
|
||||
import UserCreateModal from '../components/UserCreateModal.client.js';
|
||||
|
||||
const UsersPageClient = ({ currentUserId, refreshKey, canEdit }) => {
|
||||
const toast = useToast();
|
||||
const [users, setUsers] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editingUserId, setEditingUserId] = useState(null);
|
||||
|
||||
const [pagination, setPagination] = useState({
|
||||
page: 1,
|
||||
limit: 20,
|
||||
total: 0,
|
||||
totalPages: 0,
|
||||
});
|
||||
|
||||
const [sortBy, setSortBy] = useState('created_at');
|
||||
const [sortOrder, setSortOrder] = useState('desc');
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: 'name',
|
||||
label: 'Utilisateur',
|
||||
sortable: true,
|
||||
render: (user) => (
|
||||
<div className="flex items-center gap-3">
|
||||
<UserAvatar user={user} />
|
||||
<div>
|
||||
<div className="text-sm font-medium text-neutral-900 dark:text-white">{user.name}</div>
|
||||
<div className="text-xs text-neutral-500 dark:text-gray-400">{user.email}</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
skeleton: {
|
||||
height: 'h-4', width: '60%',
|
||||
secondary: { height: 'h-3', width: '40%' },
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'role',
|
||||
label: 'Rôle',
|
||||
sortable: true,
|
||||
render: (user) => {
|
||||
const roles = user.roles || [];
|
||||
const visible = roles.slice(0, 3);
|
||||
const overflow = roles.length - 3;
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{visible.map(role => (
|
||||
<Badge key={role.id} color={role.color} dot>{role.name}</Badge>
|
||||
))}
|
||||
{overflow > 0 && <Badge>+{overflow}</Badge>}
|
||||
{roles.length === 0 && <span className="text-xs text-neutral-400">—</span>}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
skeleton: { height: 'h-6', width: '140px', className: 'rounded-full' },
|
||||
},
|
||||
{
|
||||
key: 'email_verified',
|
||||
label: 'Statut',
|
||||
sortable: true,
|
||||
render: (user) => <StatusBadge status={user.email_verified ? 'verified' : 'unverified'} />,
|
||||
skeleton: { height: 'h-6', width: '90px', className: 'rounded-full' },
|
||||
},
|
||||
{
|
||||
key: 'created_at',
|
||||
label: 'Créé le',
|
||||
sortable: true,
|
||||
render: (user) => <RelativeDate date={user.created_at} />,
|
||||
skeleton: { height: 'h-4', width: '70%' },
|
||||
},
|
||||
...(canEdit ? [{
|
||||
key: 'actions',
|
||||
label: '',
|
||||
sortable: false,
|
||||
noWrap: true,
|
||||
align: 'right',
|
||||
render: (user) => (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => setEditingUserId(user.id)}
|
||||
icon={PencilEdit01Icon}
|
||||
>
|
||||
Modifier
|
||||
</Button>
|
||||
),
|
||||
skeleton: { height: 'h-8', width: '80px', className: 'rounded-lg' },
|
||||
}] : []),
|
||||
];
|
||||
|
||||
const fetchUsers = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const searchParams = new URLSearchParams({
|
||||
page: pagination.page.toString(),
|
||||
limit: pagination.limit.toString(),
|
||||
sortBy,
|
||||
sortOrder,
|
||||
});
|
||||
|
||||
const response = await fetch(`/zen/api/users?${searchParams}`, { credentials: 'include' });
|
||||
if (!response.ok) throw new Error(`Error: ${response.status}`);
|
||||
|
||||
const data = await response.json();
|
||||
setUsers(data.users);
|
||||
setPagination(prev => ({
|
||||
...prev,
|
||||
total: data.pagination.total,
|
||||
totalPages: data.pagination.totalPages,
|
||||
page: data.pagination.page,
|
||||
}));
|
||||
} catch (err) {
|
||||
toast.error(err.message || 'Impossible de charger les utilisateurs');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers();
|
||||
}, [sortBy, sortOrder, pagination.page, pagination.limit, refreshKey]);
|
||||
|
||||
const handlePageChange = (newPage) => setPagination(prev => ({ ...prev, page: newPage }));
|
||||
const handleLimitChange = (newLimit) => setPagination(prev => ({ ...prev, limit: newLimit, page: 1 }));
|
||||
const handleSort = (newSortBy) => {
|
||||
const newSortOrder = sortBy === newSortBy && sortOrder === 'asc' ? 'desc' : 'asc';
|
||||
setSortBy(newSortBy);
|
||||
setSortOrder(newSortOrder);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card variant="default" padding="none">
|
||||
<Table
|
||||
columns={columns}
|
||||
data={users}
|
||||
loading={loading}
|
||||
sortBy={sortBy}
|
||||
sortOrder={sortOrder}
|
||||
onSort={handleSort}
|
||||
emptyMessage="Aucun utilisateur trouvé"
|
||||
emptyDescription="La base de données est vide"
|
||||
currentPage={pagination.page}
|
||||
totalPages={pagination.totalPages}
|
||||
onPageChange={handlePageChange}
|
||||
onLimitChange={handleLimitChange}
|
||||
limit={pagination.limit}
|
||||
total={pagination.total}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{canEdit && (
|
||||
<UserEditModal
|
||||
userId={editingUserId}
|
||||
currentUserId={currentUserId}
|
||||
isOpen={!!editingUserId}
|
||||
onClose={() => setEditingUserId(null)}
|
||||
onSaved={fetchUsers}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const UsersPage = ({ user }) => {
|
||||
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
const canEdit = user?.permissions?.includes('users.manage');
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 sm:gap-6 lg:gap-8">
|
||||
<AdminHeader
|
||||
title="Utilisateurs"
|
||||
description="Gérez les comptes utilisateurs"
|
||||
action={canEdit && (
|
||||
<Button variant="primary" onClick={() => setCreateModalOpen(true)}>
|
||||
Nouvel utilisateur
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
<UsersPageClient currentUserId={user?.id} refreshKey={refreshKey} canEdit={canEdit} />
|
||||
{canEdit && (
|
||||
<UserCreateModal
|
||||
isOpen={createModalOpen}
|
||||
onClose={() => setCreateModalOpen(false)}
|
||||
onSaved={() => setRefreshKey(k => k + 1)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UsersPage;
|
||||
|
||||
registerPage({ slug: 'users', title: 'Utilisateurs', Component: UsersPage });
|
||||
@@ -0,0 +1,19 @@
|
||||
import { getSession } from '@zen/core/features/auth/actions';
|
||||
import { hasPermission, PERMISSIONS } from '@zen/core/users';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export async function protectAdmin({ redirectTo = '/auth/login', forbiddenRedirect = '/' } = {}) {
|
||||
const session = await getSession();
|
||||
if (!session) redirect(redirectTo);
|
||||
|
||||
const allowed = await hasPermission(session.user.id, PERMISSIONS.ADMIN_ACCESS);
|
||||
if (!allowed) redirect(forbiddenRedirect);
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
export async function isAdmin() {
|
||||
const session = await getSession();
|
||||
if (!session) return false;
|
||||
return hasPermission(session.user.id, PERMISSIONS.ADMIN_ACCESS);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Registre unique pour étendre l'admin sans modifier le core.
|
||||
*
|
||||
* Trois types d'extensions :
|
||||
* - widget : une tuile du tableau de bord. Côté serveur on enregistre un fetcher
|
||||
* (registerWidgetFetcher), côté client le Composant (registerWidget).
|
||||
* - navItem : une entrée de la sidebar admin (section optionnelle pour grouper).
|
||||
* - page : un composant rendu sous /admin/<slug>.
|
||||
*
|
||||
* Les Maps sont stockées sur `globalThis` via `Symbol.for` pour survivre :
|
||||
* 1. au hot-reload de Next.js dev (sinon les enregistrements disparaissent).
|
||||
* 2. à la double-instanciation du fichier — l'instrumentation hook tourne en
|
||||
* 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 REGISTRY_KEY = Symbol.for('__ZEN_ADMIN_REGISTRY__');
|
||||
if (!globalThis[REGISTRY_KEY]) {
|
||||
globalThis[REGISTRY_KEY] = {
|
||||
widgetFetchers: new Map(), // id -> async () => data
|
||||
widgetComponents: new Map(), // id -> { Component, order, permission }
|
||||
navItems: new Map(), // id -> { id, label, icon, href, basePath, 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 ---------------------------------------------------------------
|
||||
|
||||
export function registerWidgetFetcher(id, fetcher) {
|
||||
widgetFetchers.set(id, fetcher);
|
||||
}
|
||||
|
||||
export function registerWidget({ id, Component, order = 0, permission }) {
|
||||
widgetComponents.set(id, { Component, order, permission });
|
||||
}
|
||||
|
||||
export function getWidgets() {
|
||||
return [...widgetComponents.entries()]
|
||||
.map(([id, v]) => ({ id, ...v }))
|
||||
.sort((a, b) => a.order - b.order);
|
||||
}
|
||||
|
||||
// Un fetcher qui échoue n'empêche pas les autres de produire leur donnée.
|
||||
export async function collectWidgetData() {
|
||||
const entries = [...widgetFetchers.entries()];
|
||||
const results = await Promise.allSettled(
|
||||
entries.map(async ([id, fetch]) => [id, await fetch()])
|
||||
);
|
||||
const out = {};
|
||||
for (const r of results) {
|
||||
if (r.status === 'fulfilled') {
|
||||
const [id, data] = r.value;
|
||||
out[id] = data;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---- Navigation ------------------------------------------------------------
|
||||
|
||||
export function registerNavSection({ id, title, icon, order = 0 }) {
|
||||
navSections.set(id, { id, title, icon, order });
|
||||
}
|
||||
|
||||
export function registerNavItem({ id, label, icon, href, basePath, order = 0, sectionId = 'main', position, permission }) {
|
||||
navItems.set(id, { id, label, icon, href, basePath, order, sectionId, position, permission });
|
||||
}
|
||||
|
||||
export function getNavSections() {
|
||||
return [...navSections.values()].sort((a, b) => a.order - b.order);
|
||||
}
|
||||
|
||||
export function getNavItems() {
|
||||
return [...navItems.values()].sort((a, b) => a.order - b.order);
|
||||
}
|
||||
|
||||
// ---- Pages -----------------------------------------------------------------
|
||||
|
||||
export function registerPage({ slug, Component, title, breadcrumbLabel }) {
|
||||
pages.set(slug, { slug, Component, title, breadcrumbLabel });
|
||||
}
|
||||
|
||||
export function getPage(slug) {
|
||||
return pages.get(slug);
|
||||
}
|
||||
|
||||
export function getPages() {
|
||||
return [...pages.values()];
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
'use client';
|
||||
|
||||
// Import side-effects : chaque widget core s'auto-enregistre auprès du registry.
|
||||
// Ajouter un widget core = créer un nouveau fichier *.client.js et l'importer ici.
|
||||
import './users.client.js';
|
||||
@@ -0,0 +1,3 @@
|
||||
// Import side-effects : chaque widget core s'auto-enregistre auprès du registry.
|
||||
// Ajouter un widget core = créer un nouveau fichier *.server.js et l'importer ici.
|
||||
import './users.server.js';
|
||||
@@ -0,0 +1,23 @@
|
||||
'use client';
|
||||
|
||||
import { registerWidget } from '../registry.js';
|
||||
import { StatCard } from '@zen/core/shared/components';
|
||||
import { UserMultiple02Icon } from '@zen/core/shared/icons';
|
||||
|
||||
function UsersWidget({ data, loading }) {
|
||||
const newThisMonth = data?.newThisMonth ?? 0;
|
||||
return (
|
||||
<StatCard
|
||||
title="Nombre d'utilisateurs"
|
||||
value={loading ? '-' : String(data?.totalUsers ?? 0)}
|
||||
change={!loading && newThisMonth > 0 ? `+${newThisMonth} ce mois` : undefined}
|
||||
changeType="increase"
|
||||
icon={UserMultiple02Icon}
|
||||
color="text-blue-700"
|
||||
bgColor="bg-blue-700/10"
|
||||
loading={loading}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
registerWidget({ id: 'users', Component: UsersWidget, order: 10, permission: 'users.view' });
|
||||
@@ -0,0 +1,21 @@
|
||||
import { registerWidgetFetcher } from '../registry.js';
|
||||
import { query } from '@zen/core/database';
|
||||
import { fail } from '@zen/core/shared/logger';
|
||||
|
||||
registerWidgetFetcher('users', async () => {
|
||||
try {
|
||||
const result = await query(`
|
||||
SELECT
|
||||
COUNT(*) AS total,
|
||||
COUNT(CASE WHEN created_at >= NOW() - INTERVAL '30 days' THEN 1 END) AS new_this_month
|
||||
FROM zen_auth_users
|
||||
`);
|
||||
return {
|
||||
totalUsers: parseInt(result.rows[0].total) || 0,
|
||||
newThisMonth: parseInt(result.rows[0].new_this_month) || 0,
|
||||
};
|
||||
} catch (error) {
|
||||
fail(`Users widget data error: ${error.message}`);
|
||||
return { totalUsers: 0, newThisMonth: 0 };
|
||||
}
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user