Compare commits

..

70 Commits

Author SHA1 Message Date
hykocx e783a39ced chore: bump version to 1.4.122 2026-04-25 10:50:18 -04:00
hykocx a3aff9fa49 feat(modules): add external module system with auto-discovery and public pages support
- add `src/core/modules/` with registry, discovery (server), and public index
- add `src/core/public-pages/` with registry, server component, and public index
- add `src/core/users/permissions-registry.js` for runtime permission registration
- expose `./modules`, `./public-pages`, and `./public-pages/server` package exports
- rename `registerFeatureRoutes` to `registerApiRoutes` with backward-compatible alias
- extend `seedDefaultRolesAndPermissions` to include module-registered permissions
- update `initializeZen` and shared init to wire module discovery and registration
- add `docs/MODULES.md` documenting the `@zen/module-*` authoring contract
- update `docs/DEV.md` with references to module system docs
2026-04-25 10:50:13 -04:00
hykocx 3098940905 chore: bump version to 1.4.121 2026-04-25 10:12:37 -04:00
hykocx efc7c93c6b fix(auth): prevent admin from revoking their last users.manage role
- add self-lockout guard in handleRevokeUserRole api handler
- sequence role additions before removals and handle delete errors in UserEditModal
- document the security rule in core/users README
2026-04-25 10:12:31 -04:00
hykocx 78ba61e60e chore: bump version to 1.4.120 2026-04-25 10:02:54 -04:00
hykocx 0d6b06f217 feat(users): allow system roles to be renamed but not have permissions changed
- update `updateRole` to allow name changes for system roles while blocking permission updates
- remove edit button restriction for system roles in roles page
- disable name field only was replaced by disabling permissions checkboxes for system roles in edit modal
- update README to reflect new system role update policy
2026-04-25 10:02:51 -04:00
hykocx 584e96a00d chore: bump version to 1.4.119 2026-04-25 09:59:37 -04:00
hykocx 826ce3dcd1 fix(auth): prevent system roles from being updated
- throw error in updateRole when role is system-protected
- hide edit button in roles table for system roles
- update README to reflect roles cannot be modified (not just renamed)
2026-04-25 09:59:33 -04:00
hykocx ebdeea7287 chore: bump version to 1.4.118 2026-04-25 09:47:37 -04:00
hykocx 2360021376 refactor(users)!: merge users.edit and users.delete into users.manage permission
BREAKING CHANGE: permissions `users.edit` and `users.delete` have been replaced by a single `users.manage` permission; any role or code referencing the old keys must be updated

- remove `USERS_EDIT` and `USERS_DELETE` from `PERMISSIONS` and `PERMISSION_DEFINITIONS`
- add `USERS_MANAGE` permission covering create, edit and delete actions
- update `db.js` to use `users.manage` in permission checks
- update `auth/api.js` to reference the new permission key
- update `UsersPage.client.js` to check `users.manage` instead of old keys
- update `api/define.js` and all README examples to reflect the new key
2026-04-25 09:47:34 -04:00
hykocx 27ebc91d31 chore: bump version to 1.4.117 2026-04-25 09:39:06 -04:00
hykocx ab4ecd1ccf refactor(users): remove content, media, and settings permissions
- strip content.*, media.*, and settings.* permission keys from PERMISSIONS constant
- remove corresponding entries from PERMISSION_DEFINITIONS
- drop content and media permission groups from db seed data
- update README examples and permission table to reflect reduced scope
2026-04-25 09:39:00 -04:00
hykocx 2f91a8bcd3 chore: bump version to 1.4.116 2026-04-25 09:31:58 -04:00
hykocx 74bc3073a7 feat(admin): add permission-based widget visibility on dashboard
- add optional `permission` field to `registerWidget` api
- filter widgets in `DashboardPage` based on user permissions
- register users widget with `users.view` permission requirement
- document `permission` parameter in admin README
2026-04-25 09:31:54 -04:00
hykocx 01a08b0005 chore: bump version to 1.4.115 2026-04-25 09:27:10 -04:00
hykocx 97f8baf502 feat(admin): add permission-based filtering to admin navigation
- add optional `permission` field to nav items in registry
- filter nav items by user permissions in `buildNavigationSections`
- auto-hide sections when all their items are filtered out
- fetch user permissions in `AdminLayout.server.js` and pass to navigation builder
- update docs and README to document `permission` param and new signature
2026-04-25 09:27:07 -04:00
hykocx cb8266d9a9 chore: bump version to 1.4.114 2026-04-25 09:23:31 -04:00
hykocx 531381430d docs(claude): require documentation updates after every code change 2026-04-25 09:23:27 -04:00
hykocx c959b16db5 refactor(api): add granular permission enforcement on admin routes
- add optional `permission` field to route definitions with type validation in `define.js`
- check `hasPermission()` in router after `requireAdmin()` and return 403 if denied
- document `permission` and `skipRateLimit` optional fields in api README
- load user permissions in `AdminPage.server.js` and pass them to client via `user` prop
- use `user.permissions` in `RolesPage` and `UsersPage` to conditionally render actions
- expose permission-gated API routes in `auth/api.js`
2026-04-25 09:21:07 -04:00
hykocx 188e1d82f8 style(auth): polish french copy in auth email templates
- simplify em-dash sentence in EmailChangeConfirmEmail footer note
- replace "notre équipe de support" with "le support" across notify/changed/admin_new variants
- shorten InvitationEmail title by removing "Bienvenue —" prefix
- reword PasswordChangedEmail body and footer note for clarity
- align PasswordResetEmail and VerificationEmail copy with same tone
2026-04-25 09:11:20 -04:00
hykocx 0eee8af8b4 chore: bump version to 1.4.113 2026-04-25 09:06:19 -04:00
hykocx 03b24ce320 fix(auth): remove redundant truthy check in hasPassword condition 2026-04-25 09:06:16 -04:00
hykocx 3b442f2cf5 chore: bump version to 1.4.112 2026-04-25 09:04:17 -04:00
hykocx 12c1e36c3c feat(auth): export completeAccountSetup function 2026-04-25 09:04:14 -04:00
hykocx 0f199bb5cd chore: bump version to 1.4.111 2026-04-25 09:03:19 -04:00
hykocx abd9d651dc feat(auth): add user invitation flow with account setup
- add `createAccountSetup`, `verifyAccountSetupToken`, `deleteAccountSetupToken` to verifications core
- add `completeAccountSetup` function to auth core for password creation on invite
- add `InvitationEmail` template for sending invite links
- add `SetupAccountPage` client page for invited users to set their password
- add `UserCreateModal` admin component to invite new users
- wire invitation action and API endpoint in auth feature
- update admin `UsersPage` to include user creation modal
- update auth and admin README docs
2026-04-25 09:03:15 -04:00
hykocx 96c8cf1e97 chore: bump version to 1.4.110 2026-04-25 08:34:47 -04:00
hykocx eff66e0a70 style(admin): swap light/dark text colors on icon label in icons page 2026-04-25 08:34:40 -04:00
hykocx ccc6e28d9d style(admin): fix icon color to support light and dark mode 2026-04-25 08:33:41 -04:00
hykocx f481844932 docs(admin): add README documentation for admin and auth features
- add comprehensive README for admin feature covering structure, API, registry, and extension points
- add comprehensive README for auth feature covering structure, API, and usage examples
2026-04-24 21:53:47 -04:00
hykocx 203bd82dd9 docs(core): add README files for all core framework modules
- add cron/README.md documenting the node-cron wrapper API and job registration pattern
- add email/README.md documenting the Resend wrapper, env vars, and template usage
- add payments/README.md documenting the payments module
- add pdf/README.md documenting the pdf generation module
- add themes/README.md documenting the theming system
- add toast/README.md documenting the toast notification module
- add users/README.md documenting the users module
2026-04-24 21:48:31 -04:00
hykocx e1ee9ef564 chore: bump version to 1.4.109 2026-04-24 21:38:30 -04:00
hykocx 238666f9cc fix(rateLimit): return loopback ip in development to keep rate limiting active
- use `127.0.0.1` as fallback ip when `NODE_ENV === 'development'` in both `getIpFromHeaders` and `getIpFromRequest`
- preserve `unknown` fallback in production to suspend rate limiting when no trusted proxy is configured
- update comments to reflect environment-specific behaviour
2026-04-24 21:38:27 -04:00
hykocx 879fee1b80 chore: bump version to 1.4.108 2026-04-24 21:34:38 -04:00
hykocx f46116394c feat(auth): add proxy support and pass ip/user-agent to login
- add ZEN_TRUST_PROXY env variable in .env.example for reverse proxy config
- replace getClientIp() with getIpFromHeaders() using next/headers for ip resolution
- forward ipAddress and userAgent to login action for session tracking
2026-04-24 21:34:35 -04:00
hykocx f6f2938e3b chore: bump version to 1.4.107 2026-04-24 21:25:00 -04:00
hykocx 860d44d728 style(auth): replace min-h-dvh with min-h-screen on auth page container 2026-04-24 21:24:57 -04:00
hykocx 5218f3f205 chore: bump version to 1.4.106 2026-04-24 21:22:15 -04:00
hykocx 1e529a6741 style(auth): improve auth page layout for mobile viewports
- use `min-h-dvh`, `flex-col`, and top-aligned justify on small screens in AuthPage
- add `mx-auto` to all auth page cards for consistent centering
2026-04-24 21:22:12 -04:00
hykocx dd322bcc86 chore: bump version to 1.4.105 2026-04-24 21:16:28 -04:00
hykocx b39e316b4a fix(admin): improve breadcrumb segment matching for nested nav items
- replace fixed `[first, second]` destructuring with dynamic segment-aware matching
- find nav items using prefix segment comparison instead of first-segment-only match
- compute `itemSegCount` from matched nav item href to support multi-segment routes
- derive sub-segment index dynamically so breadcrumb labels resolve correctly for nested paths
2026-04-24 21:16:25 -04:00
hykocx 190664bfbe chore: bump version to 1.4.104 2026-04-24 21:12:51 -04:00
hykocx 9138474512 style(icons): increase stroke width of arrow left and up icons from 1.5 to 2 2026-04-24 21:12:49 -04:00
hykocx 00ea4af242 chore: bump version to 1.4.103 2026-04-24 21:11:58 -04:00
hykocx 1032276d49 refactor(ui): replace chevron icons with arrow icon variants
- swap `ChevronDownIcon` and `ChevronRightIcon` for `ArrowDown01Icon` and `ArrowRight01Icon` in AdminSidebar and AdminTop
- add `ArrowDown01Icon`, `ArrowLeft01Icon`, `ArrowRight01Icon`, and `ArrowUp01Icon` to shared icons index
- remove `ChevronDownIcon` and `ChevronRightIcon` from shared icons index
2026-04-24 21:11:53 -04:00
hykocx 5f625adc76 chore: bump version to 1.4.102 2026-04-24 21:10:15 -04:00
hykocx 310277f5cd refactor(ui): replace ChevronDownIcon with ArrowDown01Icon in Table
- add ArrowDown01Icon svg component to shared icons index
- update Table.js to use ArrowDown01Icon instead of ChevronDownIcon for sort indicator
2026-04-24 21:10:12 -04:00
hykocx 4474ab8204 chore: bump version to 1.4.101 2026-04-24 21:08:55 -04:00
hykocx bd31d29ac7 refactor(ui): replace ArrowDown01Icon with ChevronDownIcon in Table
- swap ArrowDown01Icon for ChevronDownIcon in Table sort indicator
- remove ArrowDown01Icon export from shared icons index
2026-04-24 21:08:52 -04:00
hykocx 4ba9cac007 chore: bump version to 1.4.100 2026-04-24 21:06:10 -04:00
hykocx a73357b759 refactor(ui): replace inline svg icons with icon components
- replace inline checkmark svg in ColorPicker with Tick02Icon
- replace inline sort arrow svg in Table with ArrowDown01Icon
- add ArrowDown01Icon to shared icons index
2026-04-24 21:06:07 -04:00
hykocx b200346d04 chore: bump version to 1.4.99 2026-04-24 21:02:36 -04:00
hykocx 759184f0ed refactor(admin): replace inline svgs with icon components and fix icon colors
- replace inline hamburger/close svg with Menu01Icon component in AdminTop
- replace inline chevron svg with ChevronRightIcon component for breadcrumbs
- add ChevronRightIcon and Menu01Icon imports to AdminTop
- fix UserCircle02Icon fill values from hardcoded #ffffff to currentColor
2026-04-24 21:02:33 -04:00
hykocx 650d2dbb27 chore: bump version to 1.4.98 2026-04-24 20:52:55 -04:00
hykocx 2d3d450e19 refactor(admin): replace inline svgs with icon components
- add `Logout02Icon` to admin top bar logout button
- add `SmartPhone01Icon` and `ComputerIcon` to profile page session list
- update icons index to use hugeicons react package imports
2026-04-24 20:52:51 -04:00
hykocx c25a518d87 refactor(ui): replace custom icon spinner with inline svg in Loading component
- remove Recycle03Icon dependency and use native svg spinner
- adjust size values for sm, md, and lg variants
- update loading text from "Loading...." to "Chargement"
2026-04-24 20:37:31 -04:00
hykocx 8d5a785494 style(ui): reduce dark mode opacity for danger, success, and warning button variants 2026-04-24 20:35:08 -04:00
hykocx 957e322f9f style(devkit): add explicit text color to card variant labels 2026-04-24 20:33:16 -04:00
hykocx f77635b7b3 chore: bump version to 1.4.97 2026-04-24 20:31:12 -04:00
hykocx 47437ecca8 style(admin): improve icons grid layout and card appearance
- increase grid columns across breakpoints including md, 2xl, and custom 16-col
- add aspect-square and justify-center to icon cards for uniform sizing
- update card style with solid border and background instead of transparent hover-only
- enlarge icon size from w-5/h-5 to w-7/h-7 and set color to white
- add full-width and padding to icon label for better text containment
2026-04-24 20:31:09 -04:00
hykocx 50f04f762b chore: bump version to 1.4.96 2026-04-24 20:27:33 -04:00
hykocx 970092fccb feat(admin): add devkit developer tools section
- add `ZEN_DEVKIT` env variable to enable/disable devkit
- add `isDevkitEnabled()` utility and export it from public api
- register devkit nav section and items conditionally when devkit is enabled
- add devkit route handling in admin page client and server
- add DevkitPage, ComponentsPage, and IconsPage client components
2026-04-24 20:27:30 -04:00
hykocx 345218641c chore: bump version to 1.4.95 2026-04-24 17:58:58 -04:00
hykocx 183d151f0f style(admin): update card width classes from min-w to max-w on profile and settings pages
- replace `sm:min-w-3/5` with `lg:max-w-4/5` on all profile page cards
- replace `min-w-3/5` with `w-full lg:max-w-4/5` on settings page cards
2026-04-24 17:58:55 -04:00
hykocx 27a9cbc12f chore: bump version to 1.4.94 2026-04-24 17:54:40 -04:00
hykocx 77ca4fe66f fix(ui): improve mobile responsiveness across admin components
- reduce app name font size from text-lg to text-sm in AdminTop mobile header
- make profile page cards full-width on mobile with sm:min-w-3/5 breakpoint
- stack photo upload layout vertically on mobile using flex-col sm:flex-row
- add flex-wrap to photo action buttons for small screens
- make TabNav horizontally scrollable with hidden scrollbar on mobile
- add shrink-0 and whitespace-nowrap to tab buttons to prevent wrapping
2026-04-24 17:54:37 -04:00
hykocx ba289d1a28 chore: bump version to 1.4.93 2026-04-24 17:51:02 -04:00
hykocx b90b4e7bcc refactor(ui): redesign mobile card layout in Table component
- replace fixed column-slice layout with mobileHidden filter for flexible column visibility
- render primary column as prominent header with semantic styling
- display remaining columns in a responsive 2-column dl grid with label/value pairs
- update MobileSkeletonCard to reflect new grid structure based on visible column count
2026-04-24 17:50:41 -04:00
hykocx 5743eb7f53 chore: bump version to 1.4.92 2026-04-24 17:48:49 -04:00
hykocx 932e9b9373 style(admin): simplify mobile menu toggle button styling 2026-04-24 17:48:46 -04:00
88 changed files with 3863 additions and 377 deletions
+7 -1
View File
@@ -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
@@ -49,4 +52,7 @@ ZEN_PUBLIC_LOGO_BLACK=
ZEN_PUBLIC_LOGO_URL=
# OTHERS
NEXT_TELEMETRY_DISABLED=1
NEXT_TELEMETRY_DISABLED=1
# DEVKIT (developer tools)
ZEN_DEVKIT=false
+6
View File
@@ -1,3 +1,9 @@
# Claude Code Rules
Always read and respect [docs/DEV.md](docs/DEV.md) at the start of every conversation before doing any work in this project.
After every code change, update the relevant documentation. This includes:
- `docs/` for cross-cutting conventions, architecture decisions, and design rules
- co-located `README.md` files in `src/core/<module>/` and `src/features/<feature>/` for module-level behaviour
No task is complete until all impacted documentation is up to date.
+5 -1
View File
@@ -12,6 +12,8 @@ Pour la procédure de publication du package : [PUBLICATION.md](./dev/PUBLICATIO
Pour les conventions de commit : [COMMITS.md](./dev/COMMITS.md).
Pour la création de modules externes `@zen/module-*` : [MODULES.md](./MODULES.md).
> **Contexte projet** : les utilisateurs finaux créent leur projet via `npx @zen/start`, qui génère automatiquement une structure Next.js avec la plateforme déjà intégré. Lors d'une assistance ou d'une revue, partez du principe que le projet cible est déjà structuré de cette façon.
---
@@ -71,6 +73,8 @@ Ces suffixes ne sont pas cosmétiques : **le build les utilise comme source de v
L'admin utilise un **registre runtime** pour permettre aux projets consommateurs d'ajouter des widgets, des entrées de navigation, et des pages sans modifier le core.
> Pour distribuer ces extensions sous forme de package npm réutilisable plutôt que de les écrire en local, voir [MODULES.md](./MODULES.md) — chaque module `@zen/module-*` installé est auto-découvert et activé.
```js
// app/zen.extensions.js — projet consommateur
import {
@@ -88,7 +92,7 @@ registerWidgetFetcher('orders', async () => ({ total: await countOrders() }));
registerWidget({ id: 'orders', Component: OrdersWidget, order: 20 });
registerNavSection({ id: 'commerce', title: 'Commerce', icon: 'ShoppingBag03Icon', order: 30 });
registerNavItem({ id: 'orders', label: 'Commandes', icon: 'ShoppingBag03Icon', href: '/admin/orders', sectionId: 'commerce' });
registerNavItem({ id: 'orders', label: 'Commandes', icon: 'ShoppingBag03Icon', href: '/admin/orders', sectionId: 'commerce', permission: 'orders.view' });
registerPage({ slug: 'orders', Component: OrdersPage, title: 'Commandes' });
```
+248
View File
@@ -0,0 +1,248 @@
# Modules externes `@zen/module-*`
Un **module** est un package npm distinct qui ajoute des fonctionnalités à un projet construit avec `@zen/core` — sans aucune modification de code dans le projet consommateur.
```bash
npm install @zen/module-billing
# ajouter les variables d'env documentées dans le README du module
npx zen-db init # crée les tables du module et seed ses permissions
npm run dev # tout est câblé : pages admin, sidebar, widgets, API, /zen/<module>/...
```
Aucun fichier de configuration manuelle. La plateforme découvre les modules par scan des dépendances `package.json`.
---
## Découverte
Au boot et au lancement de `zen-db init`, le core scanne `dependencies` + `devDependencies` du `package.json` du projet consommateur et charge tout package matchant :
- **Préfixe officiel** : `@zen/module-*`
- **Préfixe non-scopé** : `zen-module-*`
- **Tiers** : tout package dont le `package.json` contient `"keywords": ["zen-module"]`
Pour chaque module trouvé, le core vérifie qu'il exporte les bons symboles, puis l'enregistre.
---
## Forme d'un module
Le point d'entrée du package (`main` ou `exports["."]`) doit exporter :
```js
// @zen/module-blog/index.js
export const manifest = {
name: '@zen/module-blog',
version: '1.0.0',
permissions: [
{ key: 'blog.view', name: 'Voir les billets', description: 'Consultation', group_name: 'Blog' },
{ key: 'blog.manage', name: 'Gérer les billets', description: 'CRUD', group_name: 'Blog' },
],
envVars: [
{ key: 'BLOG_UPLOAD_DIR', required: false, description: 'Répertoire des médias' },
],
};
export async function register() {
// Tous les enregistrements runtime se font ici (voir API ci-dessous).
await import('./register-server.js');
}
export { createTables, dropTables } from './db.js';
```
| Export | Type | Obligatoire |
|--------|------|-------------|
| `manifest` | objet (voir ci-dessous) | oui |
| `register` | `() => void \| Promise<void>` | oui |
| `createTables` | `async () => { created?: string[], skipped?: string[] }` | si le module a des tables |
| `dropTables` | `async () => void` | si le module a des tables |
### Manifest
| Champ | Type | Description |
|-------|------|-------------|
| `name` | `string` | Nom du package (utilisé comme identifiant unique). |
| `version` | `string` | Version du module (logguée au boot). |
| `permissions` | `Array` | Permissions ajoutées au catalogue. Auto-attribuées au rôle `admin` au prochain `zen-db init`. |
| `envVars` | `Array` | Variables d'env du module ; les `required: true` absentes émettent un warning au boot. |
---
## API d'enregistrement
Toutes ces fonctions s'utilisent depuis le hook `register()` du module.
### Permissions
Déclarées dans `manifest.permissions`. Le core les enregistre automatiquement avant le seed BD et les attribue au rôle `admin`. À la connexion, l'admin peut les distribuer à d'autres rôles via `/admin/roles`.
### Sidebar admin
```js
import { registerNavSection, registerNavItem } from '@zen/core/features/admin';
registerNavSection({ id: 'blog', title: 'Blog', icon: 'Notebook01Icon', order: 40 });
registerNavItem({
id: 'blog-posts',
label: 'Billets',
icon: 'Notebook01Icon',
href: '/admin/blog',
sectionId: 'blog',
permission: 'blog.view',
});
```
### Pages admin
```js
import { registerPage } from '@zen/core/features/admin';
import BlogAdminPage from './admin/BlogAdminPage.client.js';
registerPage({ slug: 'blog', Component: BlogAdminPage, title: 'Blog' });
```
Rendue sous `/admin/blog`.
### Widgets dashboard
```js
// côté serveur
import { registerWidgetFetcher } from '@zen/core/features/admin';
registerWidgetFetcher('blog-posts', async () => ({ count: await countPosts() }));
// côté client
'use client';
import { registerWidget } from '@zen/core/features/admin';
registerWidget({ id: 'blog-posts', Component: BlogWidget, order: 40, permission: 'blog.view' });
```
### Routes API
```js
import { registerApiRoutes } from '@zen/core/api';
import { defineApiRoutes, apiSuccess } from '@zen/core/api';
const routes = defineApiRoutes([
{ path: '/blog/posts', method: 'GET', handler: handleListPosts, auth: 'admin', permission: 'blog.view' },
{ path: '/blog/posts', method: 'POST', handler: handleCreatePost, auth: 'admin', permission: 'blog.manage' },
{ path: '/blog/posts/:id', method: 'GET', handler: handleGetPost, auth: 'public' },
]);
registerApiRoutes(routes);
```
Le router applique automatiquement la session, le rate-limit et la vérification de permission. Les routes sont accessibles sous `/zen/api/*`.
| Champ `auth` | Comportement |
|--------------|--------------|
| `'public'` | Aucune session requise. |
| `'user'` | Session valide. |
| `'admin'` | Session avec permission `admin.access`. La permission granulaire `permission` est aussi vérifiée si fournie. |
### Pages publiques `/zen/<module>/...`
```js
import { registerPublicModulePage } from '@zen/core/public-pages';
import BlogPublicPage from './public/BlogPublicPage.js';
registerPublicModulePage({ moduleName: 'blog', Component: BlogPublicPage });
```
URL : `/zen/blog/<...>`. Le composant reçoit `{ params, segments }` :
```js
function BlogPublicPage({ params, segments }) {
// /zen/blog/post/abc-123 → segments = ['post', 'abc-123']
if (segments[0] === 'post') return <PostView id={segments[1]} />;
return <BlogIndex />;
}
```
Le namespace `api` est réservé aux routes API et ne peut être utilisé comme `moduleName`.
### Migrations BD
```js
// db.js
import { query, tableExists } from '@zen/core/database';
const TABLES = [
{ name: 'zen_blog_posts', sql: `CREATE TABLE zen_blog_posts (...)` },
];
export async function createTables() {
const created = [];
const skipped = [];
for (const t of TABLES) {
if (await tableExists(t.name)) { skipped.push(t.name); continue; }
await query(t.sql);
created.push(t.name);
}
return { created, skipped };
}
export async function dropTables() {
for (const t of [...TABLES].reverse()) {
await query(`DROP TABLE IF EXISTS "${t.name}" CASCADE`);
}
}
```
Convention : préfixer toutes les tables par `zen_<module>_` pour éviter les collisions.
---
## Frontières serveur/client
Comme pour le core (voir [DEV.md](DEV.md)), les fichiers du module portent les suffixes `.server.js` / `.client.js`. Le hook `register()` côté serveur est appelé par le core ; les enregistrements client (widgets, par ex.) doivent être triggés par un import dans le bundle client — typiquement via le composant client lui-même qui appelle `registerWidget()` à l'import.
```js
// Pattern recommandé pour un module avec partie client :
// src/register-server.js (importé par register())
import './admin/BlogAdminPage.client.js'; // chaîne d'imports vers client
import './widgets/BlogWidget.server.js';
// ... registerNavItem, registerPage, registerApiRoutes, etc.
```
Le bundle Next.js du projet consommateur traverse le graphe d'import et inclut les composants client dans le bundle client. Côté serveur, seules les fonctions et fetchers serveur sont chargés.
---
## Variables d'environnement
Toute variable requise par le module doit être déclarée dans `manifest.envVars` et documentée dans le `README.md` du module. Les variables `required: true` absentes génèrent un warning au boot — elles ne crashent pas le serveur, le module gère son propre fallback.
---
## Squelette minimal d'un module
```
@zen/module-blog/
├── package.json # name: "@zen/module-blog", main: "./index.js"
├── README.md # documente les env vars et la configuration
├── index.js # exporte manifest, register, createTables, dropTables
├── db.js # createTables/dropTables
├── register-server.js # imports déclencheurs (chargé par register())
├── api.js # routes API (registerApiRoutes)
├── admin/
│ ├── BlogAdminPage.client.js # registerPage + composant
│ └── widgets/... # registerWidgetFetcher + registerWidget
└── public/
└── BlogPublicPage.js # registerPublicModulePage
```
---
## Cycle de vie complet
| Étape | Côté core | Côté module |
|-------|-----------|-------------|
| Install | — | `npm install @zen/module-X` |
| Configuration | — | Ajout des `envVars` au `.env` |
| Migration BD | `zen-db init` scanne, charge le module, registerPermissions(), seed, createTables() | `createTables()` exécuté |
| Boot serveur | `instrumentation.js``initializeZen()` scanne, charge, registerPermissions(), `register()` | `register()` exécuté côté serveur |
| Premier render client | bundle client traverse les imports → composants client enregistrés | `registerWidget()` exécuté côté client |
| Runtime | router dispatch les requêtes, admin résout les pages/widgets via le registre | aucun travail supplémentaire |
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@zen/core",
"version": "1.4.91",
"version": "1.4.122",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@zen/core",
"version": "1.4.91",
"version": "1.4.122",
"license": "GPL-3.0-only",
"dependencies": {
"@headlessui/react": "^2.0.0",
+10 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@zen/core",
"version": "1.4.91",
"version": "1.4.122",
"description": "Une plateforme multi-usage construite sur l'essentiel, rien de plus, rien de moins.",
"repository": {
"type": "git",
@@ -93,6 +93,15 @@
"./users/constants": {
"import": "./dist/core/users/constants.js"
},
"./modules": {
"import": "./dist/core/modules/index.js"
},
"./public-pages": {
"import": "./dist/core/public-pages/index.js"
},
"./public-pages/server": {
"import": "./dist/core/public-pages/PublicModulePage.server.js"
},
"./api": {
"import": "./dist/core/api/index.js"
},
+8
View File
@@ -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
+9
View File
@@ -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.
+1 -1
View File
@@ -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';
+6
View File
@@ -271,6 +271,12 @@ export async function routeRequest(request, path) {
try {
if (matchedRoute.auth === 'admin') {
context.session = await requireAdmin();
if (matchedRoute.permission) {
const allowed = await hasPermission(context.session.user.id, matchedRoute.permission);
if (!allowed) {
return apiError('Forbidden', 'Permission insuffisante');
}
}
} else if (matchedRoute.auth === 'user') {
context.session = await requireAuth();
}
+11 -4
View File
@@ -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.
+132
View File
@@ -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
```
+155
View File
@@ -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`.
+23
View File
@@ -0,0 +1,23 @@
# Modules
Registre des modules `@zen/module-*` chargés dans le projet consommateur. Voir [docs/MODULES.md](../../../docs/MODULES.md) pour le guide complet de création d'un module.
## API
```js
import { registerModule, getRegisteredModules } from '@zen/core/modules';
// Le core utilise discoverModules() pour peupler ce registre automatiquement.
// La plupart des consommateurs n'appellent jamais registerModule() directement.
```
## Forme attendue d'un module
Le point d'entrée d'un package `@zen/module-X` doit exporter :
| Export | Type | Obligatoire |
|--------|------|-------------|
| `manifest` | `{ name, version, permissions?, envVars? }` | oui |
| `register` | `() => void \| Promise<void>` | oui |
| `createTables` | `async () => { created?, skipped? }` | si le module a des tables |
| `dropTables` | `async () => void` | si le module a des tables |
+129
View File
@@ -0,0 +1,129 @@
import { readFile } from 'node:fs/promises';
import { resolve, join } from 'node:path';
import { createRequire } from 'node:module';
import { info, warn, fail } from '@zen/core/shared/logger';
import { registerModule, getRegisteredModule } from './registry.js';
/**
* Découverte automatique des modules `@zen/module-*` installés dans le projet consommateur.
*
* Stratégie :
* 1. Lire `package.json` du process.cwd() (le projet consommateur, pas @zen/core).
* 2. Pour chaque dépendance dont le nom matche `^@zen/module-` ou `^zen-module-`,
* résoudre son point d'entrée et l'importer.
* 3. Pour les noms qui ne matchent pas le préfixe, fallback : lire
* `keywords` du package.json du package — si "zen-module" est présent, charger.
* 4. Valider la forme du module (manifest, register, createTables/dropTables) et
* l'enregistrer via registerModule().
*
* Cette fonction ne lance PAS les hooks register() — elle se contente de découvrir
* et d'enregistrer les modules dans le registre. Le boot (initializeZen) et le CLI
* (zen-db) consomment ensuite getRegisteredModules() selon leurs besoins.
*
* Idempotente : appeler plusieurs fois ne charge chaque module qu'une seule fois.
*/
const NAME_PREFIX = /^(@zen\/module-|zen-module-)/;
function isCandidate(name) {
return NAME_PREFIX.test(name);
}
async function readJson(path) {
try {
return JSON.parse(await readFile(path, 'utf-8'));
} catch {
return null;
}
}
async function isThirdPartyModule(name, projectCwd) {
// Fallback pour les modules tiers : on regarde le keywords du package.
const require = createRequire(join(projectCwd, 'package.json'));
let pkgJsonPath;
try {
pkgJsonPath = require.resolve(`${name}/package.json`);
} catch {
return false;
}
const pkg = await readJson(pkgJsonPath);
return Array.isArray(pkg?.keywords) && pkg.keywords.includes('zen-module');
}
async function loadModule(name) {
if (getRegisteredModule(name)) return; // déjà chargé
let mod;
try {
// Node résout via node_modules à partir du module appelant ; en pratique
// depuis dist/core/modules/ dans @zen/core (lui-même installé chez le
// consommateur), Node remonte jusqu'aux node_modules du consommateur.
mod = await import(name);
} catch (error) {
fail(`zen-modules: failed to import "${name}" — ${error.message}`);
return;
}
if (!mod.manifest || typeof mod.register !== 'function') {
warn(`zen-modules: "${name}" missing required exports (manifest, register) — skipping`);
return;
}
registerModule({
manifest: mod.manifest,
register: mod.register,
createTables: mod.createTables,
dropTables: mod.dropTables,
});
info(`zen-modules: discovered ${mod.manifest.name}@${mod.manifest.version ?? '?'}`);
}
/**
* Découvre et enregistre tous les modules installés dans le projet consommateur.
*
* @param {object} [options]
* @param {string} [options.cwd] - Répertoire racine du projet consommateur.
* @returns {Promise<{ loaded: string[] }>}
*/
export async function discoverModules({ cwd = process.cwd() } = {}) {
const pkgPath = resolve(cwd, 'package.json');
const pkg = await readJson(pkgPath);
if (!pkg) {
warn(`zen-modules: no package.json at ${pkgPath} — skipping discovery`);
return { loaded: [] };
}
const allDeps = {
...(pkg.dependencies ?? {}),
...(pkg.devDependencies ?? {}),
};
const candidates = [];
for (const name of Object.keys(allDeps)) {
if (isCandidate(name)) {
candidates.push(name);
} else if (await isThirdPartyModule(name, cwd)) {
candidates.push(name);
}
}
for (const name of candidates) {
await loadModule(name);
}
return { loaded: candidates };
}
/**
* Valide les variables d'environnement requises par chaque module.
* Ne lance pas — log un warning pour chaque variable absente.
*/
export function validateModuleEnvVars(modules) {
for (const mod of modules) {
const envVars = mod.manifest?.envVars ?? [];
for (const v of envVars) {
if (v.required && !process.env[v.key]) {
warn(`zen-modules: ${mod.manifest.name} requires env var "${v.key}" — ${v.description ?? ''}`);
}
}
}
}
+1
View File
@@ -0,0 +1 @@
export { registerModule, getRegisteredModules, getRegisteredModule, clearRegisteredModules } from './registry.js';
+45
View File
@@ -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();
}
+225
View File
@@ -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) |
+142
View File
@@ -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} />;
}
+37
View File
@@ -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.
+1
View File
@@ -0,0 +1 @@
export { registerPublicModulePage, getPublicModulePage, getPublicModulePages } from './registry.js';
+36
View File
@@ -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()];
}
+153
View File
@@ -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>
);
}
```
+145
View File
@@ -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 };
}
```
+274
View File
@@ -0,0 +1,274 @@
# Users
Ce répertoire gère les utilisateurs, l'authentification par identifiants, les sessions, les rôles et les permissions. Il constitue la couche de données auth du projet : les features l'appellent, il ne connaît pas les features.
---
## Structure
```
src/core/users/
├── index.js re-exports publics
├── auth.js register, login, mot de passe, vérification email
├── session.js création, validation, suppression de sessions
├── queries.js lecture et mise à jour des utilisateurs
├── roles.js CRUD des rôles, assignation aux utilisateurs
├── permissions.js hasPermission, getUserPermissions
├── constants.js PERMISSIONS, PERMISSION_DEFINITIONS, getPermissionGroups
├── verifications.js tokens de vérification email et de réinitialisation
├── emailChange.js tokens de changement d'adresse email
├── password.js hashPassword, verifyPassword, generateToken, generateId
└── db.js helpers internes
```
---
## Import
```js
import {
register, login, requestPasswordReset, resetPassword, verifyUserEmail, updateUser,
createSession, validateSession, deleteSession, deleteUserSessions, refreshSession,
getUserById, getUserByEmail, countUsers, listUsers, updateUserById,
createRole, updateRole, deleteRole, listRoles, getRoleById,
getUserRoles, assignUserRole, revokeUserRole,
hasPermission, getUserPermissions,
PERMISSIONS, PERMISSION_DEFINITIONS, getPermissionGroups,
hashPassword, verifyPassword, generateToken, generateId,
} from '@zen/core/users';
```
---
## API
### `register(userData, options?)`
Crée un compte utilisateur avec vérification des contraintes mot de passe. Le premier utilisateur enregistré reçoit le rôle `admin`. Retourne `{ user, verificationToken }`.
```js
const { user, verificationToken } = await register(
{ email: 'alice@example.com', password: 'Secret1', name: 'Alice' },
{
onEmailVerification: async (email, token) => {
await sendVerificationEmail({ to: email, token });
},
}
);
```
| Paramètre | Type | Description |
|-----------|------|-------------|
| `email` | `string` | Adresse email (max 254 caractères) |
| `password` | `string` | Mot de passe (8-128 caractères, au moins 1 majuscule, 1 minuscule, 1 chiffre) |
| `name` | `string` | Nom affiché (max 100 caractères) |
| `onEmailVerification` | `async (email, token) => void` | Callback pour envoyer le token de vérification |
---
### `login(credentials, sessionOptions?)`
Vérifie les identifiants et crée une session. Retourne `{ user, session }`. Lève une erreur si les identifiants sont incorrects.
```js
const { user, session } = await login(
{ email: 'alice@example.com', password: 'Secret1' },
{ ipAddress: req.ip, userAgent: req.headers['user-agent'] }
);
```
---
### `requestPasswordReset(email)`
Génère un token de réinitialisation (expire dans 1 heure). Retourne `{ success: true, token }` même si l'email est inconnu, pour éviter l'énumération.
```js
const { token } = await requestPasswordReset('alice@example.com');
```
---
### `resetPassword(data, options?)`
Valide le token et met à jour le mot de passe. Retourne `{ success: true }`.
```js
await resetPassword(
{ email: 'alice@example.com', token, newPassword: 'NewSecret1' },
{ onPasswordChanged: async (email) => { /* envoyer confirmation */ } }
);
```
---
### `verifyUserEmail(userId)`
Marque l'email de l'utilisateur comme vérifié.
```js
await verifyUserEmail(user.id);
```
---
### `updateUser(userId, data)`
Met à jour les champs autorisés du profil : `name`, `image`, `language`.
```js
await updateUser(user.id, { name: 'Alice Martin' });
```
---
### `createSession(userId, options?)`
Crée une session valide 30 jours. Retourne l'objet session.
```js
const session = await createSession(user.id, { ipAddress: '127.0.0.1', userAgent: '...' });
```
---
### `validateSession(token)`
Valide un token de session. Renouvelle automatiquement la session si elle expire dans moins de 20 jours. Retourne `{ session, user, sessionRefreshed }` ou `null`.
```js
const result = await validateSession(token);
if (!result) {
// session expirée ou invalide
}
```
---
### `deleteSession(token)` / `deleteUserSessions(userId)`
Supprime une session ou toutes les sessions d'un utilisateur.
```js
await deleteSession(token);
await deleteUserSessions(user.id);
```
---
### `getUserById(id)` / `getUserByEmail(email)`
Récupère un utilisateur par son id ou son email.
```js
const user = await getUserById('abc123');
const user = await getUserByEmail('alice@example.com');
```
---
### `listUsers(options?)`
Liste les utilisateurs avec pagination et tri. Retourne `{ users, pagination }`.
```js
const { users, pagination } = await listUsers({ page: 1, limit: 20, sortBy: 'created_at', sortOrder: 'desc' });
```
| Paramètre | Défaut | Description |
|-----------|--------|-------------|
| `page` | `1` | Page courante |
| `limit` | `10` | Résultats par page (max 100) |
| `sortBy` | `'created_at'` | Colonne de tri (`id`, `email`, `name`, `role`, `email_verified`, `created_at`) |
| `sortOrder` | `'desc'` | `'asc'` ou `'desc'` |
---
### `updateUserById(id, fields)`
Met à jour les champs autorisés d'un utilisateur : `name`, `role`, `email_verified`, `image`, `language`.
```js
await updateUserById(user.id, { role: 'editor', email_verified: true });
```
---
### Rôles
```js
const roles = await listRoles();
const role = await getRoleById(id);
const role = await createRole({ name: 'Modérateur', description: 'Peut gérer les utilisateurs', color: '#3b82f6' });
await updateRole(roleId, {
name: 'Modérateur',
permissionKeys: [PERMISSIONS.USERS_VIEW, PERMISSIONS.USERS_MANAGE],
});
await deleteRole(roleId); // impossible sur les rôles système
const userRoles = await getUserRoles(userId);
await assignUserRole(userId, roleId);
await revokeUserRole(userId, roleId);
```
Les rôles système (`is_system = true`) peuvent être renommés mais leurs permissions ne peuvent pas être modifiées. Ils ne peuvent pas être supprimés.
L'endpoint `DELETE /zen/api/users/:id/roles/:roleId` applique une règle de sécurité supplémentaire : un utilisateur ne peut pas se retirer un rôle qui lui accorde `users.manage` s'il n'en a pas d'autre. Cela évite qu'un administrateur se retrouve dans l'impossibilité de se redonner la permission. Cette vérification est faite au niveau du handler API et ne concerne pas la fonction `revokeUserRole` elle-même.
---
### Permissions
```js
const canManageRoles = await hasPermission(userId, PERMISSIONS.ROLES_MANAGE);
const keys = await getUserPermissions(userId);
```
`PERMISSIONS` contient toutes les clés disponibles. `PERMISSION_DEFINITIONS` expose le label, la description et le groupe de chaque permission. `getPermissionGroups()` retourne les permissions regroupées par `group_name`.
| Groupe | Clés |
|--------|------|
| Administration | `admin.access` |
| Utilisateurs | `users.view`, `users.manage` |
| Rôles | `roles.view`, `roles.manage` |
---
### Changement d'email
```js
import { createEmailChangeToken, verifyEmailChangeToken, applyEmailChange } from '@zen/core/users/emailChange';
const token = await createEmailChangeToken(userId, 'new@example.com');
// Plus tard, lors de la confirmation :
const result = await verifyEmailChangeToken(token);
if (result) {
await applyEmailChange(result.userId, result.newEmail);
}
```
Le token expire dans 24 heures. `verifyEmailChangeToken` retourne `null` si le token est invalide ou expiré.
---
## Gestion des erreurs
`register`, `login`, `resetPassword` lèvent des erreurs typées (`Error`) avec des messages en français. Les fonctions de requête (`getUserById`, etc.) retournent `null` si l'entrée n'existe pas. Les callbacks `onEmailVerification` et `onPasswordChanged` sont exécutés sans bloquer le flux principal : une erreur dans le callback est loguée mais n'interrompt pas l'opération.
---
## Tables utilisées
| Table | Description |
|-------|-------------|
| `zen_auth_users` | Comptes utilisateurs |
| `zen_auth_accounts` | Identifiants par provider (`credential`) |
| `zen_auth_sessions` | Sessions actives |
| `zen_auth_verifications` | Tokens de vérification email, reset mot de passe, changement email |
| `zen_auth_roles` | Rôles |
| `zen_auth_role_permissions` | Permissions associées aux rôles |
| `zen_auth_user_roles` | Rôles assignés aux utilisateurs |
+66 -2
View File
@@ -2,7 +2,7 @@ import { query, create, findOne, updateById, count } from '@zen/core/database';
import { hashPassword, verifyPassword, generateId } from './password.js';
import { createSession } from './session.js';
import { fail } from '@zen/core/shared/logger';
import { createEmailVerification, createPasswordReset, verifyResetToken, deleteResetToken } from './verifications.js';
import { createEmailVerification, createPasswordReset, verifyResetToken, deleteResetToken, verifyAccountSetupToken, deleteAccountSetupToken } from './verifications.js';
async function register(userData, { onEmailVerification } = {}) {
const { email, password, name } = userData;
@@ -228,4 +228,68 @@ async function updateUser(userId, updateData) {
return await updateById('zen_auth_users', userId, filteredData);
}
export { register, login, requestPasswordReset, resetPassword, verifyUserEmail, updateUser };
async function completeAccountSetup({ email, token, password }) {
if (!email || !token || !password) {
throw new Error('L\'e-mail, le jeton et le mot de passe sont requis');
}
if (password.length < 8) {
throw new Error('Le mot de passe doit contenir au moins 8 caractères');
}
if (password.length > 128) {
throw new Error('Le mot de passe doit contenir 128 caractères ou moins');
}
const hasUppercase = /[A-Z]/.test(password);
const hasLowercase = /[a-z]/.test(password);
const hasNumber = /\d/.test(password);
if (!hasUppercase || !hasLowercase || !hasNumber) {
throw new Error('Le mot de passe doit contenir au moins une majuscule, une minuscule et un chiffre');
}
const tokenValid = await verifyAccountSetupToken(email, token);
if (!tokenValid) {
throw new Error('Lien d\'invitation invalide ou expiré');
}
const user = await findOne('zen_auth_users', { email });
if (!user) {
throw new Error('Lien d\'invitation invalide');
}
const hashedPassword = await hashPassword(password);
const existingAccount = await findOne('zen_auth_accounts', {
user_id: user.id,
provider_id: 'credential'
});
if (existingAccount) {
await updateById('zen_auth_accounts', existingAccount.id, {
password: hashedPassword,
updated_at: new Date()
});
} else {
await create('zen_auth_accounts', {
id: generateId(),
account_id: email,
provider_id: 'credential',
user_id: user.id,
password: hashedPassword,
updated_at: new Date()
});
}
await updateById('zen_auth_users', user.id, {
email_verified: true,
updated_at: new Date()
});
await deleteAccountSetupToken(email);
return { success: true };
}
export { register, login, requestPasswordReset, resetPassword, verifyUserEmail, updateUser, completeAccountSetup };
+10 -32
View File
@@ -4,41 +4,19 @@
*/
export const PERMISSIONS = {
ADMIN_ACCESS: 'admin.access',
CONTENT_VIEW: 'content.view',
CONTENT_CREATE: 'content.create',
CONTENT_EDIT: 'content.edit',
CONTENT_DELETE: 'content.delete',
CONTENT_PUBLISH: 'content.publish',
MEDIA_VIEW: 'media.view',
MEDIA_UPLOAD: 'media.upload',
MEDIA_DELETE: 'media.delete',
USERS_VIEW: 'users.view',
USERS_EDIT: 'users.edit',
USERS_DELETE: 'users.delete',
ROLES_VIEW: 'roles.view',
ROLES_MANAGE: 'roles.manage',
SETTINGS_VIEW: 'settings.view',
SETTINGS_MANAGE: 'settings.manage',
ADMIN_ACCESS: 'admin.access',
USERS_VIEW: 'users.view',
USERS_MANAGE: 'users.manage',
ROLES_VIEW: 'roles.view',
ROLES_MANAGE: 'roles.manage',
};
export const PERMISSION_DEFINITIONS = [
{ key: 'admin.access', name: 'Accès au panneau admin', description: "Permet d'accéder à l'interface d'administration.", group_name: 'Administration' },
{ key: 'content.view', name: 'Voir le contenu', description: 'Permet de consulter les articles, pages et autres contenus.', group_name: 'Contenu' },
{ key: 'content.create', name: 'Créer du contenu', description: 'Permet de rédiger et soumettre de nouveaux contenus.', group_name: 'Contenu' },
{ key: 'content.edit', name: 'Modifier le contenu', description: 'Permet de mettre à jour des contenus existants.', group_name: 'Contenu' },
{ key: 'content.delete', name: 'Supprimer le contenu', description: 'Permet de supprimer définitivement des contenus.', group_name: 'Contenu' },
{ key: 'content.publish', name: 'Publier le contenu', description: 'Permet de rendre des contenus visibles publiquement.', group_name: 'Contenu' },
{ key: 'media.view', name: 'Voir les médias', description: 'Permet de parcourir la médiathèque.', group_name: 'Médias' },
{ key: 'media.upload', name: 'Téléverser des médias', description: 'Permet d\'uploader des images, vidéos et fichiers.', group_name: 'Médias' },
{ key: 'media.delete', name: 'Supprimer des médias', description: 'Permet de supprimer des fichiers de la médiathèque.', group_name: 'Médias' },
{ key: 'users.view', name: 'Voir les utilisateurs', description: 'Permet de consulter la liste des membres et leurs profils.', group_name: 'Utilisateurs' },
{ key: 'users.edit', name: 'Modifier les utilisateurs', description: 'Permet de changer les informations et les rôles des membres.', group_name: 'Utilisateurs' },
{ key: 'users.delete', name: 'Supprimer des utilisateurs', description: 'Permet de supprimer des comptes membres.', group_name: 'Utilisateurs' },
{ key: 'roles.view', name: 'Voir les rôles', description: 'Permet de consulter la liste des rôles et leurs permissions.', group_name: 'Rôles' },
{ key: 'roles.manage', name: 'Gérer les rôles', description: 'Permet de créer, modifier et supprimer des rôles.', group_name: 'Rôles' },
{ key: 'settings.view', name: 'Voir les paramètres', description: 'Permet de consulter la configuration du site.', group_name: 'Paramètres' },
{ key: 'settings.manage', name: 'Gérer les paramètres', description: 'Permet de modifier la configuration et les réglages du site.', group_name: 'Paramètres' },
{ key: 'admin.access', name: 'Accès au panneau admin', description: "Permet d'accéder à l'interface d'administration.", group_name: 'Administration' },
{ key: 'users.view', name: 'Voir les utilisateurs', description: 'Permet de consulter la liste des membres et leurs profils.', group_name: 'Utilisateurs' },
{ key: 'users.manage', name: 'Gérer les utilisateurs', description: 'Permet de cer, modifier et supprimer des comptes membres.', group_name: 'Utilisateurs' },
{ key: 'roles.view', name: 'Voir les rôles', description: 'Permet de consulter la liste des rôles et leurs permissions.', group_name: 'Rôles' },
{ key: 'roles.manage', name: 'Gérer les rôles', description: 'Permet de créer, modifier et supprimer des rôles.', group_name: 'Rôles' },
];
/**
+36 -11
View File
@@ -2,8 +2,9 @@ import { query, tableExists } from '@zen/core/database';
import { generateId } from './password.js';
import { done, warn } from '@zen/core/shared/logger';
import { PERMISSION_DEFINITIONS } from './constants.js';
import { registerPermissions, getRegisteredPermissions } from './permissions-registry.js';
const USER_ROLE_PERMISSIONS = ['content.view', 'media.view'];
const USER_ROLE_PERMISSIONS = [];
const ROLE_TABLES = [
{
@@ -66,15 +67,37 @@ async function dropRoleCheckConstraint() {
`);
}
async function migratePermissions() {
// Migrate users.edit / users.delete → users.manage
await query(`
INSERT INTO zen_auth_role_permissions (role_id, permission_key)
SELECT DISTINCT role_id, 'users.manage'
FROM zen_auth_role_permissions
WHERE permission_key IN ('users.edit', 'users.delete')
AND EXISTS (SELECT 1 FROM zen_auth_permissions WHERE key = 'users.manage')
ON CONFLICT DO NOTHING
`);
await query(`DELETE FROM zen_auth_role_permissions WHERE permission_key IN ('users.edit', 'users.delete')`);
await query(`DELETE FROM zen_auth_permissions WHERE key IN ('users.edit', 'users.delete')`);
}
async function seedDefaultRolesAndPermissions() {
// Permissions
for (const perm of PERMISSION_DEFINITIONS) {
// S'assure que les permissions core sont dans le registre, puis seed depuis
// le registre — qui contient core + permissions enregistrées par les modules.
registerPermissions(PERMISSION_DEFINITIONS);
const allPermissions = getRegisteredPermissions();
for (const perm of allPermissions) {
await query(
`INSERT INTO zen_auth_permissions (key, name, group_name) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING`,
[perm.key, perm.name, perm.group_name]
`INSERT INTO zen_auth_permissions (key, name, description, group_name)
VALUES ($1, $2, $3, $4)
ON CONFLICT (key) DO UPDATE SET name = EXCLUDED.name, description = EXCLUDED.description, group_name = EXCLUDED.group_name`,
[perm.key, perm.name, perm.description, perm.group_name]
);
}
await migratePermissions();
// Admin role
const adminRoleId = generateId();
await query(
@@ -84,12 +107,14 @@ async function seedDefaultRolesAndPermissions() {
const adminRole = await query(`SELECT id FROM zen_auth_roles WHERE name = 'admin'`);
const adminId = adminRole.rows[0].id;
for (const perm of PERMISSION_DEFINITIONS) {
await query(
`INSERT INTO zen_auth_role_permissions (role_id, permission_key) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
[adminId, perm.key]
);
}
// Toute permission présente dans le catalogue est attribuée au rôle admin —
// y compris les permissions ajoutées par les modules après le premier init.
await query(
`INSERT INTO zen_auth_role_permissions (role_id, permission_key)
SELECT $1, key FROM zen_auth_permissions
ON CONFLICT DO NOTHING`,
[adminId]
);
// User role
const userRoleId = generateId();
+11 -1
View File
@@ -4,4 +4,14 @@ export { createSession, validateSession, deleteSession, deleteUserSessions, refr
export { createEmailVerification, verifyEmailToken, createPasswordReset, verifyResetToken, deleteResetToken } from './verifications.js';
export { hashPassword, verifyPassword, generateToken, generateId } from './password.js';
export { listRoles, getRoleById, createRole, updateRole, deleteRole, getUserRoles, assignUserRole, revokeUserRole } from './roles.js';
export { PERMISSIONS, PERMISSION_DEFINITIONS, getPermissionGroups, hasPermission, getUserPermissions } from './permissions.js';
export {
PERMISSIONS,
PERMISSION_DEFINITIONS,
getPermissionGroups,
hasPermission,
getUserPermissions,
registerPermission,
registerPermissions,
getRegisteredPermissions,
getRegisteredPermissionKeys,
} from './permissions.js';
+47
View File
@@ -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();
}
+6
View File
@@ -1,5 +1,11 @@
import { query } from '@zen/core/database';
export { PERMISSIONS, PERMISSION_DEFINITIONS, getPermissionGroups } from './constants.js';
export {
registerPermission,
registerPermissions,
getRegisteredPermissions,
getRegisteredPermissionKeys,
} from './permissions-registry.js';
export async function hasPermission(userId, permissionKey) {
const result = await query(
+5 -7
View File
@@ -1,8 +1,6 @@
import { query, transaction } from '@zen/core/database';
import { generateId } from './password.js';
import { PERMISSIONS } from './permissions.js';
const VALID_PERMISSION_KEYS = new Set(Object.values(PERMISSIONS));
import { getRegisteredPermissionKeys } from './permissions-registry.js';
export async function listRoles() {
const result = await query(
@@ -60,8 +58,7 @@ export async function updateRole(roleId, { name, description, color, permissionK
const values = [];
let idx = 1;
// System roles cannot be renamed
if (!isSystem && name !== undefined) {
if (name !== undefined) {
if (!name.trim()) throw new Error('Role name cannot be empty');
updateFields.push(`name = $${idx++}`);
values.push(name.trim());
@@ -83,8 +80,9 @@ export async function updateRole(roleId, { name, description, color, permissionK
values
);
if (permissionKeys !== undefined) {
const safeKeys = [...new Set(permissionKeys)].filter(k => VALID_PERMISSION_KEYS.has(k));
if (!isSystem && permissionKeys !== undefined) {
const validKeys = getRegisteredPermissionKeys();
const safeKeys = [...new Set(permissionKeys)].filter(k => validKeys.has(k));
await client.query(`DELETE FROM zen_auth_role_permissions WHERE role_id = $1`, [roleId]);
for (const key of safeKeys) {
await client.query(
+49 -1
View File
@@ -98,4 +98,52 @@ function deleteResetToken(email) {
return deleteWhere('zen_auth_verifications', { identifier: 'password_reset', value: email });
}
export { createEmailVerification, verifyEmailToken, createPasswordReset, verifyResetToken, deleteResetToken };
async function createAccountSetup(email) {
const token = generateToken(32);
const expiresAt = new Date();
expiresAt.setHours(expiresAt.getHours() + 48);
await deleteWhere('zen_auth_verifications', { identifier: 'account_setup', value: email });
const setup = await create('zen_auth_verifications', {
id: generateId(),
identifier: 'account_setup',
value: email,
token,
expires_at: expiresAt,
updated_at: new Date()
});
return { ...setup, token };
}
async function verifyAccountSetupToken(email, token) {
const setup = await findOne('zen_auth_verifications', {
identifier: 'account_setup',
value: email
});
if (!setup) return false;
const storedBuf = Buffer.from(setup.token, 'utf8');
const providedBuf = Buffer.from(
token.length === setup.token.length ? token : setup.token,
'utf8'
);
const tokensMatch = crypto.timingSafeEqual(storedBuf, providedBuf)
&& token.length === setup.token.length;
if (!tokensMatch) return false;
if (new Date(setup.expires_at) < new Date()) {
await deleteWhere('zen_auth_verifications', { id: setup.id });
return false;
}
return true;
}
function deleteAccountSetupToken(email) {
return deleteWhere('zen_auth_verifications', { identifier: 'account_setup', value: email });
}
export { createEmailVerification, verifyEmailToken, createPasswordReset, verifyResetToken, deleteResetToken, createAccountSetup, verifyAccountSetupToken, deleteAccountSetupToken };
+3 -1
View File
@@ -3,12 +3,14 @@ import { protectAdmin } from './protect.js';
import { buildNavigationSections, buildBottomNavItems } from './navigation.js';
import { logoutAction } from '@zen/core/features/auth/actions';
import { getAppName } from '@zen/core';
import { getUserPermissions } from '@zen/core/users';
import './widgets/index.server.js';
export default async function AdminLayout({ children }) {
const session = await protectAdmin();
const appName = getAppName();
const navigationSections = buildNavigationSections('/');
const permissions = await getUserPermissions(session.user.id);
const navigationSections = buildNavigationSections('/', permissions);
const bottomNavItems = buildBottomNavItems('/');
return (
+5 -1
View File
@@ -8,8 +8,9 @@ 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';
export default function AdminPageClient({ params, user, widgetData, appConfig }) {
export default function AdminPageClient({ params, user, widgetData, appConfig, devkitEnabled }) {
const parts = params?.admin || [];
const [first] = parts;
@@ -25,5 +26,8 @@ export default function AdminPageClient({ params, user, widgetData, appConfig })
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} />;
}
+10 -2
View File
@@ -2,19 +2,27 @@ 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 = await collectWidgetData();
const [widgetData, permissions] = await Promise.all([
collectWidgetData(),
getUserPermissions(session.user.id),
]);
const appConfig = { ...getAppConfig(), siteUrl: getPublicBaseUrl() };
const devkitEnabled = isDevkitEnabled();
const user = { ...session.user, permissions };
return (
<AdminPageClient
params={resolvedParams}
user={session.user}
user={user}
widgetData={widgetData}
appConfig={appConfig}
devkitEnabled={devkitEnabled}
/>
);
}
+300
View File
@@ -0,0 +1,300 @@
# Admin
Ce répertoire fournit l'interface d'administration complète : layout, navigation, tableau de bord, gestion des utilisateurs et des rôles. Il expose un **registre runtime** pour permettre aux projets consommateurs d'ajouter des widgets, des entrées de navigation et des pages sans modifier le core.
> Le pattern `zen.extensions.js` documenté ici reste valide pour les extensions in-projet (extensions ad hoc spécifiques à une app). Pour distribuer une extension réutilisable comme un package npm, consulter [docs/MODULES.md](../../../docs/MODULES.md) — la même API d'enregistrement s'utilise mais le module est auto-découvert via les `dependencies` du projet consommateur.
---
## Structure
```
src/features/admin/
├── index.js protectAdmin, isAdmin, buildNavigationSections, registre
├── protect.js gardes d'accès
├── navigation.js buildNavigationSections, buildBottomNavItems
├── registry.js registre runtime d'extensions
├── AdminLayout.server.js layout RSC de l'admin
├── AdminPage.server.js page RSC racine (protège + collecte les données widgets)
├── AdminPage.client.js shell client
├── components/
│ ├── index.js re-export
│ ├── AdminHeader.js
│ ├── AdminShell.js
│ ├── AdminSidebar.js
│ ├── AdminTop.js
│ ├── RoleEditModal.client.js
│ ├── ThemeToggle.js
│ ├── UserCreateModal.client.js
│ └── UserEditModal.client.js
├── devkit/
│ ├── ComponentsPage.client.js
│ ├── DevkitPage.client.js
│ └── IconsPage.client.js
├── pages/
│ ├── ConfirmEmailChangePage.client.js
│ ├── DashboardPage.client.js
│ ├── ProfilePage.client.js
│ ├── RolesPage.client.js
│ ├── SettingsPage.client.js
│ └── UsersPage.client.js
└── widgets/
├── index.client.js auto-registration des widgets core (côté client)
├── index.server.js auto-registration des widgets core (côté serveur)
├── users.client.js widget Utilisateurs (composant)
└── users.server.js widget Utilisateurs (fetcher)
```
---
## Import
```js
import { protectAdmin, isAdmin, buildNavigationSections } from '@zen/core/features/admin';
import {
registerWidget,
registerWidgetFetcher,
registerNavItem,
registerNavSection,
registerPage,
} from '@zen/core/features/admin';
```
---
## Pages intégrées
| Route | Page |
|-------|------|
| `/admin/dashboard` | Tableau de bord avec widgets |
| `/admin/users` | Liste, création et gestion des utilisateurs |
| `/admin/roles` | Gestion des rôles et permissions |
| `/admin/settings` | Paramètres de l'application |
| `/admin/profile` | Profil de l'utilisateur connecté |
| `/admin/confirm-email-change` | Confirmation de changement d'email |
---
## API
### `protectAdmin(options?)`
Garde serveur. Redirige si l'utilisateur n'est pas connecté ou n'a pas la permission `ADMIN_ACCESS`. Retourne la session courante.
```js
const session = await protectAdmin();
// session.user est disponible
```
| Option | Type | Défaut | Description |
|--------|------|--------|-------------|
| `redirectTo` | `string` | `'/auth/login'` | Redirection si non authentifié |
| `forbiddenRedirect` | `string` | `'/'` | Redirection si non autorisé |
---
### `isAdmin()`
Vérifie si l'utilisateur courant a la permission `ADMIN_ACCESS`. Retourne `boolean`.
```js
const admin = await isAdmin();
if (!admin) return null;
```
---
### `buildNavigationSections(pathname, userPermissions?)`
Construit les sections de navigation pour la sidebar à partir du registre. Marque l'entrée active selon `pathname`. Les items dont le champ `permission` n'est pas présent dans `userPermissions` sont automatiquement exclus ; si tous les items d'une section sont exclus, la section disparaît également.
```js
const permissions = await getUserPermissions(session.user.id);
const sections = buildNavigationSections('/admin/users', permissions);
// [{ id, title, icon, items: [{ name, href, icon, current }] }]
```
---
## Registre d'extensions
Le registre permet d'ajouter des widgets, des entrées de navigation et des pages sans toucher au core. Les enregistrements se font via des imports à effet de bord dans le layout racine du projet consommateur.
### Ajouter un widget
Un widget est composé de deux parties : un fetcher serveur qui collecte les données, et un composant client qui les affiche.
```js
// app/admin/orders/ordersWidget.server.js
import { registerWidgetFetcher } from '@zen/core/features/admin';
import { countOrders } from './orders.server.js';
registerWidgetFetcher('orders', async () => ({
total: await countOrders(),
}));
```
```js
// app/admin/orders/ordersWidget.client.js
'use client';
import { registerWidget } from '@zen/core/features/admin';
import { StatCard } from '@zen/core/shared/components';
function OrdersWidget({ data, loading }) {
return (
<StatCard
title="Commandes"
value={loading ? '-' : String(data?.total ?? 0)}
loading={loading}
/>
);
}
registerWidget({ id: 'orders', Component: OrdersWidget, order: 20 });
```
Le composant reçoit `data` (retour du fetcher) et `loading` (booléen). Si le fetcher échoue, `data` est `null` et `loading` reste `false`.
**`registerWidgetFetcher(id, fetcher)`**
| Paramètre | Type | Description |
|-----------|------|-------------|
| `id` | `string` | Identifiant unique du widget |
| `fetcher` | `async () => object` | Fonction serveur qui retourne les données |
**`registerWidget({ id, Component, order?, permission? })`**
| Paramètre | Type | Description |
|-----------|------|-------------|
| `id` | `string` | Identifiant unique (doit correspondre au fetcher) |
| `Component` | `ReactComponent` | Composant client affiché dans le tableau de bord |
| `order` | `number` | Position dans la grille (défaut : `0`) |
| `permission` | `string` | Clé de permission requise pour voir ce widget (ex. `'users.view'`). Le widget est masqué si l'utilisateur ne possède pas cette permission. |
---
### Ajouter une entrée de navigation
```js
import { registerNavSection, registerNavItem } from '@zen/core/features/admin';
registerNavSection({ id: 'commerce', title: 'Commerce', icon: 'ShoppingBag03Icon', order: 30 });
registerNavItem({
id: 'orders',
label: 'Commandes',
icon: 'ShoppingBag03Icon',
href: '/admin/orders',
sectionId: 'commerce',
order: 10,
permission: 'orders.view', // optionnel — masqué si l'utilisateur n'a pas cette permission
});
```
**`registerNavSection({ id, title, icon, order? })`**
| Paramètre | Type | Description |
|-----------|------|-------------|
| `id` | `string` | Identifiant unique de la section |
| `title` | `string` | Titre affiché dans la sidebar |
| `icon` | `string` | Nom d'icône Hugeicons |
| `order` | `number` | Ordre d'affichage (défaut : `0`) |
**`registerNavItem({ id, label, icon, href, sectionId?, order?, position?, permission? })`**
| Paramètre | Type | Description |
|-----------|------|-------------|
| `id` | `string` | Identifiant unique de l'entrée |
| `label` | `string` | Texte affiché |
| `icon` | `string` | Nom d'icône Hugeicons |
| `href` | `string` | URL de destination |
| `sectionId` | `string` | Section parente (défaut : `'main'`) |
| `order` | `number` | Ordre d'affichage (défaut : `0`) |
| `position` | `string` | `'bottom'` pour épingler en bas de la sidebar |
| `permission` | `string` | Clé de permission requise pour voir cette entrée (ex. `'orders.view'`). L'entrée est masquée si l'utilisateur ne possède pas cette permission. |
---
### Ajouter une page
```js
import { registerPage } from '@zen/core/features/admin';
import OrdersPage from './OrdersPage.js';
registerPage({
slug: 'orders',
Component: OrdersPage,
title: 'Commandes',
});
```
La page est rendue sous `/admin/<slug>`. `AdminPage.client.js` résout le composant à partir du slug dans les paramètres de route.
**`registerPage({ slug, Component, title?, breadcrumbLabel? })`**
| Paramètre | Type | Description |
|-----------|------|-------------|
| `slug` | `string` | Segment d'URL sous `/admin/` |
| `Component` | `ReactComponent` | Composant client rendu pour cette route |
| `title` | `string` | Titre de la page (optionnel) |
| `breadcrumbLabel` | `string` | Label du fil d'Ariane (optionnel, défaut : `title`) |
---
## Câbler les extensions dans le projet consommateur
Regrouper tous les enregistrements dans un fichier de point d'entrée unique, puis l'importer une seule fois depuis le layout racine.
```js
// app/zen.extensions.js
import './admin/orders/ordersWidget.server.js';
import './admin/orders/ordersWidget.client.js';
import { registerNavSection, registerNavItem, registerPage } from '@zen/core/features/admin';
import OrdersPage from './admin/orders/OrdersPage.js';
registerNavSection({ id: 'commerce', title: 'Commerce', icon: 'ShoppingBag03Icon', order: 30 });
registerNavItem({ id: 'orders', label: 'Commandes', icon: 'ShoppingBag03Icon', href: '/admin/orders', sectionId: 'commerce', permission: 'orders.view' });
registerPage({ slug: 'orders', Component: OrdersPage, title: 'Commandes' });
```
```js
// app/layout.js
import './zen.extensions'; // les side effects enregistrent tout
```
---
## DevKit
Le DevKit est une section de l'admin réservée au développement. Il expose une galerie de composants et un catalogue d'icônes. Il s'active via la variable d'environnement `ZEN_DEVKIT_ENABLED=true` et n'est jamais rendu en production.
| Route | Contenu |
|-------|---------|
| `/admin/devkit/components` | Galerie des composants partagés |
| `/admin/devkit/icons` | Catalogue d'icônes Hugeicons |
---
## Ajouter un widget core
Les widgets intégrés au core suivent le même pattern que les widgets consommateurs, avec une étape supplémentaire : déclarer les fichiers dans les index d'auto-registration.
```js
// src/features/admin/widgets/myWidget.server.js
import { registerWidgetFetcher } from '../registry.js';
registerWidgetFetcher('myWidget', async () => ({ ... }));
// src/features/admin/widgets/index.server.js
import './myWidget.server.js'; // ajouter cette ligne
```
```js
// src/features/admin/widgets/myWidget.client.js
'use client';
import { registerWidget } from '../registry.js';
// ...
registerWidget({ id: 'myWidget', Component: MyWidget, order: 20 });
// src/features/admin/widgets/index.client.js
import './myWidget.client.js'; // ajouter cette ligne
```
@@ -4,7 +4,7 @@ import React, { useState, useEffect } from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import * as Icons from '@zen/core/shared/icons';
import { ChevronDownIcon } from '@zen/core/shared/icons';
import { ArrowDown01Icon } from '@zen/core/shared/icons';
/**
* Resolve icon name (string) to icon component
@@ -127,7 +127,7 @@ const AdminSidebar = ({ isMobileMenuOpen, setIsMobileMenuOpen, appName, enabledM
<Icon className="h-[15px] w-[15px] flex-shrink-0" />
<span>{section.title}</span>
</div>
<ChevronDownIcon
<ArrowDown01Icon
className={`h-3 w-3 transition-transform duration-[120ms] ease-out ${
isCollapsed ? '-rotate-90' : 'rotate-0'
}`}
+20 -19
View File
@@ -2,7 +2,7 @@
import { Fragment, useState, useEffect } from 'react';
import { Menu, MenuButton, MenuItem, MenuItems, Transition } from '@headlessui/react';
import { ChevronDownIcon, User03Icon, DashboardSquare03Icon } from '@zen/core/shared/icons';
import { ArrowDown01Icon, ArrowRight01Icon, Menu01Icon, User03Icon, DashboardSquare03Icon, Logout02Icon } from '@zen/core/shared/icons';
import { UserAvatar } from '@zen/core/shared/components';
import { useRouter, usePathname } from 'next/navigation';
import { getPage, getPages } from '../registry.js';
@@ -47,7 +47,6 @@ const AdminTop = ({ isMobileMenuOpen, setIsMobileMenuOpen, user, onLogout, appNa
const crumbs = [{ icon: DashboardSquare03Icon, href: '/admin/dashboard' }];
const after = pathname.replace(/^\/admin\/?/, '');
const segments = after.split('/').filter(Boolean);
const [first, second] = segments;
if (!after || !segments.length || (segments[0] === 'dashboard' && segments.length === 1)) {
crumbs.push({ label: pageTitle });
@@ -55,8 +54,15 @@ const AdminTop = ({ isMobileMenuOpen, setIsMobileMenuOpen, user, onLogout, appNa
}
const allItems = navigationSections.flatMap(s => s.items);
const navItem = allItems.find(item => item.href.replace('/admin/', '').split('/')[0] === first);
const hasSubPage = segments.length > 1;
const navItem = allItems.find(item => {
const itemSegs = item.href.replace('/admin/', '').split('/').filter(Boolean);
return itemSegs.length <= segments.length && itemSegs.every((seg, i) => segments[i] === seg);
});
const itemSegCount = navItem
? navItem.href.replace('/admin/', '').split('/').filter(Boolean).length
: 1;
const hasSubPage = segments.length > itemSegCount;
if (navItem) {
crumbs.push({ label: navItem.name, href: hasSubPage ? navItem.href : undefined });
@@ -65,10 +71,11 @@ const AdminTop = ({ isMobileMenuOpen, setIsMobileMenuOpen, user, onLogout, appNa
return crumbs;
}
if (second === 'new') {
const subSegment = segments[itemSegCount];
if (subSegment === 'new') {
crumbs.push({ label: 'Nouveau' });
} else if (second === 'edit') {
const page = getPages().find(p => p.slug === `${first}:edit`);
} else if (subSegment === 'edit') {
const page = getPages().find(p => p.slug === `${segments[0]}:edit`);
crumbs.push({ label: page?.breadcrumbLabel || page?.title || 'Modifier' });
}
@@ -84,14 +91,12 @@ const AdminTop = ({ isMobileMenuOpen, setIsMobileMenuOpen, user, onLogout, appNa
<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"
className="p-1 rounded-md text-neutral-500 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white transition-colors duration-150"
aria-label="Toggle menu"
>
<svg className={`h-5 w-5 transition-transform duration-200 ${isMobileMenuOpen ? 'rotate-90' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={isMobileMenuOpen ? "M6 18L18 6M6 6l12 12" : "M4 6h16M4 12h16M4 18h16"} />
</svg>
<Menu01Icon className="h-5 w-5 transition-transform duration-200" />
</button>
<h1 className="text-neutral-900 dark:text-white font-semibold text-lg">{appName}</h1>
<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 */}
@@ -99,9 +104,7 @@ const AdminTop = ({ isMobileMenuOpen, setIsMobileMenuOpen, user, onLogout, appNa
{breadcrumbs.length > 0 && breadcrumbs.map((crumb, i) => (
<Fragment key={i}>
{i > 0 && (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-neutral-400 dark:text-neutral-600 flex-shrink-0">
<polyline points="9 18 15 12 9 6" />
</svg>
<ArrowRight01Icon className="w-3 h-3 text-neutral-400 dark:text-neutral-600 flex-shrink-0" />
)}
{crumb.icon ? (
<button
@@ -134,7 +137,7 @@ const AdminTop = ({ isMobileMenuOpen, setIsMobileMenuOpen, user, onLogout, appNa
<span className="hidden sm:block text-[13px] leading-none font-medium text-neutral-800 dark:text-white">
{user?.name || 'User'}
</span>
<ChevronDownIcon className="w-3.5 h-3.5 shrink-0 text-neutral-400 dark:text-neutral-600 transition-transform duration-200 group-data-open:rotate-180" />
<ArrowDown01Icon className="w-3.5 h-3.5 shrink-0 text-neutral-400 dark:text-neutral-600 transition-transform duration-200 group-data-open:rotate-180" />
</MenuButton>
<Transition
@@ -176,9 +179,7 @@ const AdminTop = ({ isMobileMenuOpen, setIsMobileMenuOpen, user, onLogout, appNa
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"
>
<svg className="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
<Logout02Icon className="w-4 h-4 shrink-0" />
Se déconnecter
</button>
</MenuItem>
@@ -3,9 +3,6 @@
import { useState, useEffect } from 'react';
import { Input, Textarea, Switch, Modal, ColorPicker } from '@zen/core/shared/components';
import { useToast } from '@zen/core/toast';
import { getPermissionGroups } from '@zen/core/users/constants';
const PERMISSION_GROUPS = getPermissionGroups();
const RoleEditModal = ({ roleId, isOpen, onClose, onSaved }) => {
const toast = useToast();
@@ -19,9 +16,12 @@ const RoleEditModal = ({ roleId, isOpen, onClose, onSaved }) => {
const [description, setDescription] = useState('');
const [color, setColor] = useState('#6b7280');
const [selectedPerms, setSelectedPerms] = useState([]);
// Catalogue dynamique des permissions (core + modules), récupéré via l'API.
const [permissionGroups, setPermissionGroups] = useState({});
useEffect(() => {
if (!isOpen) return;
fetchPermissions();
if (isNew) {
setName('');
setDescription('');
@@ -33,6 +33,18 @@ const RoleEditModal = ({ roleId, isOpen, onClose, onSaved }) => {
fetchRole();
}, [isOpen, roleId]);
const fetchPermissions = async () => {
try {
const response = await fetch('/zen/api/permissions', { credentials: 'include' });
if (!response.ok) return;
const data = await response.json();
setPermissionGroups(data.groups || {});
} catch {
// Si le catalogue n'est pas joignable, on laisse l'utilisateur sauvegarder
// ses changements ; les permissions invalides sont filtrées côté serveur.
}
};
const fetchRole = async () => {
try {
setLoading(true);
@@ -125,7 +137,6 @@ const RoleEditModal = ({ roleId, isOpen, onClose, onSaved }) => {
label="Nom du rôle"
value={name}
onChange={setName}
disabled={isSystem}
placeholder="Éditeur, Modérateur..."
required
/>
@@ -147,7 +158,7 @@ const RoleEditModal = ({ roleId, isOpen, onClose, onSaved }) => {
<div className="flex flex-col gap-3">
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-500 dark:text-neutral-400">Permissions</p>
{Object.entries(PERMISSION_GROUPS).map(([group, perms]) => (
{Object.entries(permissionGroups).map(([group, perms]) => (
<div key={group} className="rounded-xl border border-neutral-200 dark:border-neutral-700/60 overflow-hidden">
<div className="px-4 py-2.5 bg-neutral-50 dark:bg-neutral-800/60 border-b border-neutral-200 dark:border-neutral-700/60">
<p className="text-[11px] font-medium font-ibm-plex-mono text-neutral-500 dark:text-neutral-400 uppercase tracking-wide">
@@ -162,6 +173,7 @@ const RoleEditModal = ({ roleId, isOpen, onClose, onSaved }) => {
onChange={() => togglePerm(perm.key)}
label={perm.name}
description={perm.description}
disabled={isSystem}
/>
))}
</div>
@@ -0,0 +1,157 @@
'use client';
import { useState, useEffect } from 'react';
import { Input, TagInput, Modal, RoleBadge } from '@zen/core/shared/components';
import { useToast } from '@zen/core/toast';
const UserCreateModal = ({ isOpen, onClose, onSaved }) => {
const toast = useToast();
const [saving, setSaving] = useState(false);
const [allRoles, setAllRoles] = useState([]);
const [selectedRoleIds, setSelectedRoleIds] = useState([]);
const [formData, setFormData] = useState({ name: '', email: '', password: '' });
const [errors, setErrors] = useState({});
const [error, setError] = useState('');
useEffect(() => {
if (!isOpen) return;
setFormData({ name: '', email: '', password: '' });
setSelectedRoleIds([]);
setErrors({});
setError('');
fetchRoles();
}, [isOpen]);
const fetchRoles = async () => {
try {
const res = await fetch('/zen/api/roles', { credentials: 'include' });
const data = await res.json();
setAllRoles(data.roles || []);
} catch {
toast.error('Impossible de charger les rôles');
}
};
const handleInputChange = (field, value) => {
setFormData(prev => ({ ...prev, [field]: value }));
if (errors[field]) setErrors(prev => ({ ...prev, [field]: null }));
if (error) setError('');
};
const validate = () => {
const newErrors = {};
if (!formData.name.trim()) newErrors.name = 'Le nom est requis';
if (!formData.email.trim()) newErrors.email = 'Le courriel est requis';
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = async () => {
if (!validate()) return;
setSaving(true);
setError('');
try {
const res = await fetch('/zen/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
name: formData.name.trim(),
email: formData.email.trim(),
password: formData.password || undefined,
roleIds: selectedRoleIds,
}),
});
const data = await res.json();
if (!res.ok) {
setError(data.message || data.error || "Impossible de créer l'utilisateur");
return;
}
if (data.invited) {
toast.success('Utilisateur créé — invitation envoyée par courriel');
} else {
toast.success('Utilisateur créé');
}
onSaved?.();
onClose();
} catch {
setError("Impossible de créer l'utilisateur");
} finally {
setSaving(false);
}
};
const roleOptions = allRoles.map(r => ({
value: r.id,
label: r.name,
color: r.color || '#6b7280',
description: r.description || undefined,
}));
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title="Nouvel utilisateur"
onSubmit={handleSubmit}
submitLabel="Créer"
loading={saving}
size="md"
>
<div className="flex flex-col gap-4">
{error && (
<div className="p-3 bg-red-50 border border-red-200 rounded-lg dark:bg-red-500/10 dark:border-red-500/20">
<div className="flex items-center gap-2">
<div className="w-1.5 h-1.5 bg-red-500 rounded-full flex-shrink-0" />
<span className="text-xs text-red-700 dark:text-red-400">{error}</span>
</div>
</div>
)}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Input
label="Nom complet *"
value={formData.name}
onChange={(value) => handleInputChange('name', value)}
placeholder="Prénom Nom"
error={errors.name}
/>
<Input
label="Courriel *"
type="email"
value={formData.email}
onChange={(value) => handleInputChange('email', value)}
placeholder="utilisateur@exemple.com"
error={errors.email}
/>
</div>
<TagInput
label="Rôles"
options={roleOptions}
value={selectedRoleIds}
onChange={setSelectedRoleIds}
placeholder="Rechercher un rôle..."
renderTag={(opt, onRemove) => (
<RoleBadge key={opt.value} name={opt.label} color={opt.color} onRemove={onRemove} />
)}
/>
<div className="flex flex-col gap-1">
<Input
label="Mot de passe"
type="password"
value={formData.password}
onChange={(value) => handleInputChange('password', value)}
placeholder="Laisser vide pour envoyer une invitation"
/>
<p className="text-xs text-neutral-500 dark:text-neutral-400">
Si vide, un courriel d'invitation sera envoyé pour que l'utilisateur crée son mot de passe.
</p>
</div>
</div>
</Modal>
);
};
export default UserCreateModal;
@@ -123,22 +123,33 @@ const UserEditModal = ({ userId, currentUserId, isOpen, onClose, onSaved }) => {
const toAdd = selectedRoleIds.filter(id => !initialRoleIds.includes(id));
const toRemove = initialRoleIds.filter(id => !selectedRoleIds.includes(id));
await Promise.all([
...toAdd.map(roleId =>
await Promise.all(
toAdd.map(roleId =>
fetch(`/zen/api/users/${userId}/roles`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ roleId }),
})
),
...toRemove.map(roleId =>
)
);
const removeResults = await Promise.all(
toRemove.map(roleId =>
fetch(`/zen/api/users/${userId}/roles/${roleId}`, {
method: 'DELETE',
credentials: 'include',
})
),
]);
}).then(async res => ({ res, data: await res.json() }))
)
);
const failedRemove = removeResults.find(({ res }) => !res.ok);
if (failedRemove) {
toast.error(failedRemove.data?.message || failedRemove.data?.error || 'Impossible de retirer ce rôle');
onSaved?.();
onClose();
return;
}
if (emailChanged) {
if (isSelf) {
+1
View File
@@ -7,3 +7,4 @@ export { default as AdminHeader } from './AdminHeader.js';
export { default as ThemeToggle } from './ThemeToggle.js';
export { default as UserEditModal } from './UserEditModal.client.js';
export { default as RoleEditModal } from './RoleEditModal.client.js';
export { default as UserCreateModal } from './UserCreateModal.client.js';
@@ -0,0 +1,173 @@
'use client';
import {
Button,
Card,
Badge,
StatusBadge,
Input,
Select,
Textarea,
Switch,
StatCard,
Loading,
} from '@zen/core/shared/components';
import { UserCircle02Icon } from '@zen/core/shared/icons';
import AdminHeader from '../components/AdminHeader.js';
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="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="Loading">
<Loading />
</PreviewBlock>
</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,73 @@
'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 toast = useToast();
const filtered = useMemo(() => {
if (!query.trim()) return ALL_ICONS;
const q = query.trim().toLowerCase();
return ALL_ICONS.filter(([name]) => name.toLowerCase().includes(q));
}, [query]);
const handleCopy = (name) => {
navigator.clipboard.writeText(`<${name} className="w-5 h-5" />`);
toast.success(`${name} copié`);
};
return (
<div className="flex flex-col gap-6">
<AdminHeader
title="Icônes"
description={`${ALL_ICONS.length} icônes disponibles`}
/>
<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 &ldquo;{query}&rdquo;
</p>
) : (
<div className="grid grid-cols-4 sm:grid-cols-6 md:grid-cols-8 lg:grid-cols-10 xl:grid-cols-12 2xl:grid-cols-[repeat(16,minmax(0,1fr))] gap-2">
{filtered.map(([name, IconComponent]) => (
<button
key={name}
onClick={() => handleCopy(name)}
title={name}
className="aspect-square flex flex-col items-center justify-center gap-2 rounded-lg border border-neutral-200 dark:border-neutral-800 bg-neutral-100 dark:bg-neutral-900 hover:border-blue-500 dark:hover:border-blue-500 transition-colors duration-100 group cursor-pointer p-2"
>
<IconComponent className="w-7 h-7 text-black dark:text-white" />
<span className="text-[9px] text-neutral-500 dark:text-neutral-400 leading-tight text-center break-all line-clamp-2 group-hover:text-neutral-600 dark:group-hover:text-neutral-300 w-full px-1">
{name.replace('Icon', '')}
</span>
</button>
))}
</div>
)}
</div>
);
}
+19 -4
View File
@@ -4,23 +4,38 @@ import {
getNavSections,
getNavItems,
} from './registry.js';
import { isDevkitEnabled } from '../../shared/lib/appConfig.js';
import { PERMISSIONS } from '@zen/core/users';
// Sections et items core — enregistrés à l'import de ce module.
registerNavSection({ id: 'dashboard', title: 'Tableau de bord', icon: 'DashboardSquare03Icon', order: 10 });
registerNavSection({ id: 'system', title: 'Utilisateurs', icon: 'UserMultiple02Icon', order: 20 });
registerNavItem({ id: 'dashboard', label: 'Tableau de bord', icon: 'DashboardSquare03Icon', href: '/admin/dashboard', sectionId: 'dashboard', order: 10 });
registerNavItem({ id: 'users', label: 'Utilisateurs', icon: 'UserMultiple02Icon', href: '/admin/users', sectionId: 'system', order: 10 });
registerNavItem({ id: 'roles', label: 'Rôles', icon: 'Crown03Icon', href: '/admin/roles', sectionId: 'system', order: 20 });
registerNavItem({ id: 'users', label: 'Utilisateurs', icon: 'UserMultiple02Icon', href: '/admin/users', sectionId: 'system', order: 10, permission: PERMISSIONS.USERS_VIEW });
registerNavItem({ id: 'roles', label: 'Rôles', icon: 'Crown03Icon', href: '/admin/roles', sectionId: 'system', order: 20, permission: PERMISSIONS.ROLES_VIEW });
registerNavItem({ id: 'settings', label: 'Paramètres', icon: 'Settings02Icon', href: '/admin/settings', position: 'bottom', order: 10 });
if (isDevkitEnabled()) {
registerNavSection({ id: 'devkit', title: 'DevKit', icon: 'Wrench01Icon', order: 90 });
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 });
}
/**
* Build sections for AdminSidebar. Items are sérialisables (pas de composants),
* icônes en chaînes résolues côté client.
* @param {string} pathname
* @param {string[]} [userPermissions] - Permissions de l'utilisateur connecté ; les items
* avec un champ `permission` sont masqués si la permission n'est pas présente.
*/
export function buildNavigationSections(pathname) {
export function buildNavigationSections(pathname, userPermissions = []) {
const sections = getNavSections();
const items = getNavItems().filter(item => item.position !== 'bottom');
const items = getNavItems().filter(item => {
if (item.position === 'bottom') return false;
if (item.permission && !userPermissions.includes(item.permission)) return false;
return true;
});
const bySection = new Map();
for (const item of items) {
@@ -3,9 +3,10 @@
import { getWidgets, registerPage } from '../registry.js';
import AdminHeader from '../components/AdminHeader.js';
export default function DashboardPage({ stats }) {
export default function DashboardPage({ user, stats }) {
const loading = stats === null || stats === undefined;
const widgets = getWidgets();
const permissions = user?.permissions ?? [];
const widgets = getWidgets().filter(w => !w.permission || permissions.includes(w.permission));
return (
<div className="flex flex-col gap-4 sm:gap-6 lg:gap-8">
+9 -12
View File
@@ -3,6 +3,7 @@
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';
@@ -239,7 +240,7 @@ const ProfilePage = ({ user: initialUser }) => {
{activeTab === 'informations' && (
<Card
title="Informations personnelles"
className="min-w-3/5"
className="w-full lg:max-w-4/5"
footer={
<div className="flex items-center justify-end gap-3">
<Button
@@ -309,7 +310,7 @@ const ProfilePage = ({ user: initialUser }) => {
{activeTab === 'securite' && (
<Card
title="Changer le mot de passe"
className="min-w-3/5"
className="w-full lg:max-w-4/5"
footer={
<div className="flex items-center justify-end gap-3">
<Button
@@ -377,7 +378,7 @@ const ProfilePage = ({ user: initialUser }) => {
{activeTab === 'sessions' && (
<Card
title="Sessions actives"
className="min-w-3/5"
className="w-full lg:max-w-4/5"
footer={
<div className="flex justify-end">
<Button
@@ -403,13 +404,9 @@ const ProfilePage = ({ user: initialUser }) => {
<div key={session.id} className="flex items-center justify-between gap-4 py-3 first:pt-0 last:pb-0">
<div className="flex items-center gap-3">
{session.device === 'mobile' ? (
<svg className="w-8 h-8 text-neutral-400 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M10.5 1.5H8.25A2.25 2.25 0 006 3.75v16.5a2.25 2.25 0 002.25 2.25h7.5A2.25 2.25 0 0018 20.25V3.75a2.25 2.25 0 00-2.25-2.25H13.5m-3 0V3h3V1.5m-3 0h3m-3 8.25h3" />
</svg>
<SmartPhone01Icon className="w-8 h-8 text-neutral-500 dark:text-neutral-400 shrink-0" />
) : (
<svg className="w-8 h-8 text-neutral-400 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 17.25v1.007a3 3 0 01-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0115 18.257V17.25m6-12V15a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 15V5.25m18 0A2.25 2.25 0 0018.75 3H5.25A2.25 2.25 0 003 5.25m18 0H3" />
</svg>
<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">
@@ -443,11 +440,11 @@ const ProfilePage = ({ user: initialUser }) => {
)}
{activeTab === 'photo' && (
<Card title="Photo de profil" className="min-w-3/5">
<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 items-center gap-6">
<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 && (
@@ -456,7 +453,7 @@ const ProfilePage = ({ user: initialUser }) => {
</div>
)}
</div>
<div className="flex gap-2">
<div className="flex flex-wrap gap-2">
<input
ref={fileInputRef}
type="file"
+22 -17
View File
@@ -8,7 +8,7 @@ import { useToast } from '@zen/core/toast';
import AdminHeader from '../components/AdminHeader.js';
import RoleEditModal from '../components/RoleEditModal.client.js';
const RolesPageClient = () => {
const RolesPageClient = ({ canManage }) => {
const toast = useToast();
const [roles, setRoles] = useState([]);
const [loading, setLoading] = useState(true);
@@ -73,7 +73,7 @@ const RolesPageClient = () => {
render: (role) => role.is_system ? <Badge variant="default" size="sm">système</Badge> : null,
skeleton: { height: 'h-4', width: '60px' },
},
{
...(canManage ? [{
key: 'actions',
label: '',
sortable: false,
@@ -99,7 +99,7 @@ const RolesPageClient = () => {
</div>
),
skeleton: { height: 'h-8', width: '80px', className: 'rounded-lg' },
},
}] : []),
];
const fetchRoles = async () => {
@@ -161,14 +161,17 @@ const RolesPageClient = () => {
);
};
const RolesPage = () => (
<div className="flex flex-col gap-4 sm:gap-6 lg:gap-8">
<RolesPageHeader />
<RolesPageClient />
</div>
);
const RolesPage = ({ user }) => {
const canManage = user?.permissions?.includes('roles.manage');
return (
<div className="flex flex-col gap-4 sm:gap-6 lg:gap-8">
<RolesPageHeader canManage={canManage} />
<RolesPageClient canManage={canManage} />
</div>
);
};
const RolesPageHeader = () => {
const RolesPageHeader = ({ canManage }) => {
const [modalOpen, setModalOpen] = useState(false);
return (
@@ -176,17 +179,19 @@ const RolesPageHeader = () => {
<AdminHeader
title="Rôles"
description="Gérez les rôles et leurs permissions"
action={
action={canManage && (
<Button variant="primary" onClick={() => setModalOpen(true)}>
Nouveau rôle
</Button>
}
/>
<RoleEditModal
roleId="new"
isOpen={modalOpen}
onClose={() => setModalOpen(false)}
)}
/>
{canManage && (
<RoleEditModal
roleId="new"
isOpen={modalOpen}
onClose={() => setModalOpen(false)}
/>
)}
</>
);
};
@@ -38,7 +38,7 @@ const SettingsPage = ({ appConfig = {} }) => {
<TabNav tabs={TABS} activeTab={activeTab} onTabChange={setActiveTab} />
{activeTab === 'general' && (
<Card title="Informations générales" className='min-w-3/5'>
<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"
@@ -70,7 +70,7 @@ const SettingsPage = ({ appConfig = {} }) => {
)}
{activeTab === 'appearance' && (
<Card title="Thème" className='min-w-3/5'>
<Card title="Thème" className='w-full lg:max-w-4/5'>
<div className="max-w-xs">
<Select
label="Thème de l'interface"
+41 -17
View File
@@ -7,8 +7,9 @@ import { PencilEdit01Icon } from '@zen/core/shared/icons';
import { useToast } from '@zen/core/toast';
import AdminHeader from '../components/AdminHeader.js';
import UserEditModal from '../components/UserEditModal.client.js';
import UserCreateModal from '../components/UserCreateModal.client.js';
const UsersPageClient = ({ currentUserId }) => {
const UsersPageClient = ({ currentUserId, refreshKey, canEdit }) => {
const toast = useToast();
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
@@ -77,7 +78,7 @@ const UsersPageClient = ({ currentUserId }) => {
render: (user) => <RelativeDate date={user.created_at} />,
skeleton: { height: 'h-4', width: '70%' },
},
{
...(canEdit ? [{
key: 'actions',
label: '',
sortable: false,
@@ -93,7 +94,7 @@ const UsersPageClient = ({ currentUserId }) => {
</Button>
),
skeleton: { height: 'h-8', width: '80px', className: 'rounded-lg' },
},
}] : []),
];
const fetchUsers = async () => {
@@ -126,7 +127,7 @@ const UsersPageClient = ({ currentUserId }) => {
useEffect(() => {
fetchUsers();
}, [sortBy, sortOrder, pagination.page, pagination.limit]);
}, [sortBy, sortOrder, pagination.page, pagination.limit, refreshKey]);
const handlePageChange = (newPage) => setPagination(prev => ({ ...prev, page: newPage }));
const handleLimitChange = (newLimit) => setPagination(prev => ({ ...prev, limit: newLimit, page: 1 }));
@@ -157,23 +158,46 @@ const UsersPageClient = ({ currentUserId }) => {
/>
</Card>
<UserEditModal
userId={editingUserId}
currentUserId={currentUserId}
isOpen={!!editingUserId}
onClose={() => setEditingUserId(null)}
onSaved={fetchUsers}
/>
{canEdit && (
<UserEditModal
userId={editingUserId}
currentUserId={currentUserId}
isOpen={!!editingUserId}
onClose={() => setEditingUserId(null)}
onSaved={fetchUsers}
/>
)}
</>
);
};
const UsersPage = ({ user }) => (
<div className="flex flex-col gap-4 sm:gap-6 lg:gap-8">
<AdminHeader title="Utilisateurs" description="Gérez les comptes utilisateurs" />
<UsersPageClient currentUserId={user?.id} />
</div>
);
const UsersPage = ({ user }) => {
const [createModalOpen, setCreateModalOpen] = useState(false);
const [refreshKey, setRefreshKey] = useState(0);
const canEdit = user?.permissions?.includes('users.manage');
return (
<div className="flex flex-col gap-4 sm:gap-6 lg:gap-8">
<AdminHeader
title="Utilisateurs"
description="Gérez les comptes utilisateurs"
action={canEdit && (
<Button variant="primary" onClick={() => setCreateModalOpen(true)}>
Nouvel utilisateur
</Button>
)}
/>
<UsersPageClient currentUserId={user?.id} refreshKey={refreshKey} canEdit={canEdit} />
{canEdit && (
<UserCreateModal
isOpen={createModalOpen}
onClose={() => setCreateModalOpen(false)}
onSaved={() => setRefreshKey(k => k + 1)}
/>
)}
</div>
);
};
export default UsersPage;
+4 -4
View File
@@ -25,8 +25,8 @@ export function registerWidgetFetcher(id, fetcher) {
widgetFetchers.set(id, fetcher);
}
export function registerWidget({ id, Component, order = 0 }) {
widgetComponents.set(id, { Component, order });
export function registerWidget({ id, Component, order = 0, permission }) {
widgetComponents.set(id, { Component, order, permission });
}
export function getWidgets() {
@@ -57,8 +57,8 @@ export function registerNavSection({ id, title, icon, order = 0 }) {
navSections.set(id, { id, title, icon, order });
}
export function registerNavItem({ id, label, icon, href, order = 0, sectionId = 'main', position }) {
navItems.set(id, { id, label, icon, href, order, sectionId, position });
export function registerNavItem({ id, label, icon, href, order = 0, sectionId = 'main', position, permission }) {
navItems.set(id, { id, label, icon, href, order, sectionId, position, permission });
}
export function getNavSections() {
+1 -1
View File
@@ -20,4 +20,4 @@ function UsersWidget({ data, loading }) {
);
}
registerWidget({ id: 'users', Component: UsersWidget, order: 10 });
registerWidget({ id: 'users', Component: UsersWidget, order: 10, permission: 'users.view' });
+5
View File
@@ -8,6 +8,7 @@ import ForgotPasswordPage from './pages/ForgotPasswordPage.client.js';
import ResetPasswordPage from './pages/ResetPasswordPage.client.js';
import ConfirmEmailPage from './pages/ConfirmEmailPage.client.js';
import LogoutPage from './pages/LogoutPage.client.js';
import SetupAccountPage from './pages/SetupAccountPage.client.js';
const PAGE_COMPONENTS = {
login: LoginPage,
@@ -16,6 +17,7 @@ const PAGE_COMPONENTS = {
reset: ResetPasswordPage,
confirm: ConfirmEmailPage,
logout: LogoutPage,
setup: SetupAccountPage,
};
export default function AuthPage({
@@ -26,6 +28,7 @@ export default function AuthPage({
forgotPasswordAction,
resetPasswordAction,
verifyEmailAction,
setupAccountAction,
logoutAction,
setSessionCookieAction,
redirectAfterLogin = '/',
@@ -81,6 +84,8 @@ export default function AuthPage({
return <Page {...common} onSubmit={resetPasswordAction} email={email} token={token} />;
case ConfirmEmailPage:
return <Page {...common} onSubmit={verifyEmailAction} email={email} token={token} />;
case SetupAccountPage:
return <Page {...common} onSubmit={setupAccountAction} email={email} token={token} />;
case LogoutPage:
return <Page onLogout={logoutAction} onSetSessionCookie={setSessionCookieAction} />;
default:
+3 -1
View File
@@ -6,6 +6,7 @@ import {
forgotPasswordAction,
resetPasswordAction,
verifyEmailAction,
setupAccountAction,
setSessionCookie,
getSession,
} from './actions.js';
@@ -14,7 +15,7 @@ export default async function AuthPage({ params, searchParams }) {
const session = await getSession();
return (
<div className="min-h-screen flex items-center justify-center p-4 md:p-8 bg-neutral-50 dark:bg-black">
<div className="min-h-screen flex flex-col items-center justify-start sm:justify-center px-4 py-10 sm:py-8 md:p-8 bg-neutral-50 dark:bg-black">
<div className="max-w-md w-full">
<AuthPageClient
params={params}
@@ -25,6 +26,7 @@ export default async function AuthPage({ params, searchParams }) {
forgotPasswordAction={forgotPasswordAction}
resetPasswordAction={resetPasswordAction}
verifyEmailAction={verifyEmailAction}
setupAccountAction={setupAccountAction}
setSessionCookieAction={setSessionCookie}
redirectAfterLogin={process.env.ZEN_AUTH_REDIRECT_AFTER_LOGIN || '/'}
currentUser={session?.user || null}
+273
View File
@@ -0,0 +1,273 @@
# Auth
Ce répertoire gère l'authentification : inscription, connexion, sessions, réinitialisation de mot de passe, vérification d'adresse courriel et gestion du profil. Il expose des server actions Next.js, des routes API REST et des composants de pages prêts à l'emploi.
---
## Structure
```
src/features/auth/
├── index.js barrel serveur
├── actions.js server actions Next.js ('use server')
├── api.js routes API REST (users, roles)
├── auth.js register, login, resetPassword, updateUser, completeAccountSetup
├── session.js createSession, validateSession, deleteSession
├── email.js tokens de vérification + envoi des e-mails
├── password.js hashPassword, verifyPassword, generateToken
├── db.js createTables, dropTables
├── storage-policies.js politiques d'accès au stockage
├── AuthPage.server.js page RSC racine (route catch-all)
├── AuthPage.client.js shell client
├── GUIDE-custom-login.md guide pour les pages personnalisées
├── components/
│ └── AuthPageHeader.js
├── pages/
│ ├── index.js re-export
│ ├── LoginPage.client.js
│ ├── RegisterPage.client.js
│ ├── ForgotPasswordPage.client.js
│ ├── ResetPasswordPage.client.js
│ ├── ConfirmEmailPage.client.js
│ ├── SetupAccountPage.client.js
│ └── LogoutPage.client.js
└── templates/
├── VerificationEmail.js
├── PasswordResetEmail.js
├── PasswordChangedEmail.js
├── EmailChangeConfirmEmail.js
├── EmailChangeNotifyEmail.js
└── InvitationEmail.js
```
---
## Import
```js
import { getSession, loginAction, logoutAction } from '@zen/core/features/auth/actions';
import { LoginPage, RegisterPage } from '@zen/core/features/auth/pages';
import { validateSession, createSession } from '@zen/core/features/auth';
```
---
## Pages intégrées
La route catch-all `app/auth/[...auth]/page.js` suffit pour exposer toutes les pages sans configuration supplémentaire.
```js
// app/auth/[...auth]/page.js
export { default } from '@zen/core/features/auth/server';
```
| Route | Page |
|-------|------|
| `/auth/login` | Connexion |
| `/auth/register` | Inscription |
| `/auth/forgot` | Mot de passe oublié |
| `/auth/reset` | Réinitialisation du mot de passe |
| `/auth/confirm` | Vérification de l'adresse courriel |
| `/auth/setup` | Configuration du compte après invitation admin |
| `/auth/logout` | Déconnexion |
---
## Server actions
Toutes les actions sont dans `@zen/core/features/auth/actions`. Elles attendent un `FormData` sauf `getSession`, `setSessionCookie` et `refreshSessionCookie`.
### `getSession()`
Lit le cookie de session et retourne la session courante, ou `null` si l'utilisateur n'est pas connecté. Renouvelle automatiquement le cookie si la session a été rafraîchie.
```js
const session = await getSession();
if (!session?.user) redirect('/auth/login');
// session.user, session.session disponibles
```
---
### `loginAction(formData)`
Authentifie l'utilisateur et pose un cookie `HttpOnly`. Applique le rate limiting par IP et les vérifications anti-bot.
```js
const result = await loginAction(formData);
// { success: true, user } ou { success: false, error }
```
---
### `registerAction(formData)`
Crée un compte et envoie l'e-mail de vérification.
| Champ | Description |
|-------|-------------|
| `email` | Adresse courriel |
| `password` | Mot de passe |
| `name` | Nom d'affichage |
---
### `logoutAction()`
Invalide la session en base et supprime le cookie.
---
### `forgotPasswordAction(formData)`
Envoie un lien de réinitialisation si un compte existe pour l'adresse fournie. La réponse ne révèle pas si le compte existe.
---
### `resetPasswordAction(formData)`
Vérifie le token puis met à jour le mot de passe.
| Champ | Description |
|-------|-------------|
| `email` | Adresse courriel |
| `token` | Token reçu par e-mail |
| `newPassword` | Nouveau mot de passe |
---
### `verifyEmailAction(formData)`
Vérifie le token de confirmation et marque l'adresse comme vérifiée.
| Champ | Description |
|-------|-------------|
| `email` | Adresse courriel |
| `token` | Token reçu par e-mail |
---
### `setupAccountAction(formData)`
Vérifie le token d'invitation, crée le compte credential et marque l'adresse comme vérifiée. Appelée depuis `/auth/setup` après qu'un admin a créé le compte sans mot de passe.
| Champ | Description |
|-------|-------------|
| `email` | Adresse courriel |
| `token` | Token reçu dans le courriel d'invitation |
| `newPassword` | Mot de passe choisi |
| `confirmPassword` | Confirmation du mot de passe |
---
### `setSessionCookie(token)`
Valide le token contre la base avant de l'écrire dans le cookie `HttpOnly`. À utiliser après une authentification externe ou OAuth.
---
### `refreshSessionCookie(token)`
Revalide le token et prolonge la durée de vie du cookie (30 jours).
---
## Routes API REST
Les routes sont enregistrées automatiquement sous le préfixe `/zen/api`. L'authentification est appliquée par le routeur avant chaque handler.
### Utilisateurs
| Méthode | Route | Auth | Description |
|---------|-------|------|-------------|
| `GET` | `/zen/api/users` | admin | Liste paginée des utilisateurs |
| `POST` | `/zen/api/users` | admin | Créer un utilisateur (avec ou sans invitation) |
| `GET` | `/zen/api/users/:id` | admin | Détail d'un utilisateur |
| `PUT` | `/zen/api/users/:id` | admin | Modifier `name`, `role`, `email_verified` |
| `PUT` | `/zen/api/users/:id/email` | admin | Changer l'adresse courriel |
| `PUT` | `/zen/api/users/:id/password` | admin | Définir un mot de passe |
| `POST` | `/zen/api/users/:id/send-password-reset` | admin | Envoyer un lien de réinitialisation |
| `GET` | `/zen/api/users/:id/roles` | admin | Lister les rôles de l'utilisateur |
| `POST` | `/zen/api/users/:id/roles` | admin | Assigner un rôle |
| `DELETE` | `/zen/api/users/:id/roles/:roleId` | admin | Révoquer un rôle |
### Profil (utilisateur connecté)
| Méthode | Route | Description |
|---------|-------|-------------|
| `PUT` | `/zen/api/users/profile` | Modifier le nom |
| `POST` | `/zen/api/users/profile/email` | Initier un changement d'adresse courriel |
| `GET` | `/zen/api/users/email/confirm` | Confirmer le changement d'adresse |
| `POST` | `/zen/api/users/profile/password` | Changer le mot de passe |
| `POST` | `/zen/api/users/profile/picture` | Téléverser une photo de profil |
| `DELETE` | `/zen/api/users/profile/picture` | Supprimer la photo de profil |
| `GET` | `/zen/api/users/profile/sessions` | Lister les sessions actives |
| `DELETE` | `/zen/api/users/profile/sessions` | Révoquer toutes les sessions |
| `DELETE` | `/zen/api/users/profile/sessions/:sessionId` | Révoquer une session |
### Rôles
| Méthode | Route | Description |
|---------|-------|-------------|
| `GET` | `/zen/api/roles` | Lister les rôles |
| `POST` | `/zen/api/roles` | Créer un rôle |
| `GET` | `/zen/api/roles/:id` | Détail d'un rôle |
| `PUT` | `/zen/api/roles/:id` | Modifier un rôle |
| `DELETE` | `/zen/api/roles/:id` | Supprimer un rôle |
---
## Invitation par l'admin
Un administrateur peut créer un utilisateur depuis `/admin/users → Nouvel utilisateur`. Deux flux selon si un mot de passe est fourni :
**Avec mot de passe :** l'utilisateur est créé avec `email_verified = true` et un compte credential. Il peut se connecter immédiatement.
**Sans mot de passe :** l'utilisateur est créé avec `email_verified = false` et aucun compte credential. Un token `account_setup` (48 h) est généré et un courriel d'invitation est envoyé. L'utilisateur clique sur le lien `/auth/setup?email=X&token=Y`, choisit son mot de passe, et le compte est activé (`email_verified = true`) en une seule étape — le passage par le lien vaut confirmation du courriel.
```
Admin crée l'utilisateur (sans mdp)
→ POST /zen/api/users
→ zen_auth_users créé (email_verified: false)
→ token account_setup enregistré dans zen_auth_verifications (48 h)
→ courriel InvitationEmail envoyé
Utilisateur clique sur le lien /auth/setup
→ SetupAccountPage (setupAccountAction)
→ token vérifié
→ zen_auth_accounts créé avec mot de passe haché
→ email_verified = true
→ token supprimé
→ redirection vers /auth/login
```
---
## Sécurité
**Rate limiting par IP.** Les actions `register`, `login`, `forgot_password`, `reset_password` et `verify_email` sont limitées par adresse IP. Quand l'IP est inconnue (pas de proxy configuré), le rate limiting est suspendu et un avertissement opérateur est émis une seule fois. Activer avec `ZEN_TRUST_PROXY=true` derrière un reverse proxy vérifié.
**Champs anti-bot.** Chaque formulaire embarque un champ honeypot (`_hp`) et un timestamp de chargement (`_t`). Une soumission trop rapide (moins de 1,5 s), trop ancienne (plus de 10 min) ou avec un honeypot rempli est rejetée.
**Cookie HttpOnly.** Le token de session n'est jamais exposé à JavaScript. `setSessionCookie` et `refreshSessionCookie` valident le token en base avant d'écrire le cookie pour éviter qu'un token arbitraire soit accepté.
**Erreurs opaques.** Les erreurs internes sont loguées côté serveur et remplacées par un message générique côté client. Seules les `UserFacingError` (token expiré, etc.) remontent verbatim.
---
## Base de données
`db.js` expose `createTables()` et `dropTables()`, appelés par `initializeZen()`.
| Table | Contenu |
|-------|---------|
| `zen_auth_users` | Utilisateurs (`id`, `email`, `name`, `role`, `email_verified`, `image`) |
| `zen_auth_sessions` | Sessions actives avec IP et user-agent |
| `zen_auth_accounts` | Comptes liés à un provider (credential, OAuth) |
| `zen_auth_verifications` | Tokens de vérification d'e-mail et de réinitialisation |
---
## Pages personnalisées
Pour envelopper les pages auth dans un layout existant, voir [GUIDE-custom-login.md](./GUIDE-custom-login.md). Le guide couvre le pattern serveur/client, les props de chaque composant et la protection de route.
+41 -4
View File
@@ -1,6 +1,6 @@
'use server';
import { register, login, requestPasswordReset, resetPassword, verifyUserEmail } from './auth.js';
import { register, login, requestPasswordReset, resetPassword, verifyUserEmail, completeAccountSetup } from './auth.js';
import { validateSession, deleteSession } from './session.js';
import { verifyEmailToken, verifyResetToken, sendVerificationEmail, sendPasswordResetEmail } from './email.js';
import { fail } from '@zen/core/shared/logger';
@@ -121,7 +121,8 @@ export async function loginAction(formData) {
const botCheck = validateAntiBotFields(formData);
if (!botCheck.valid) return { success: false, error: botCheck.error };
const ip = await getClientIp();
const h = await headers();
const ip = getIpFromHeaders(h);
const rl = enforceRateLimit(ip, 'login');
if (rl && !rl.allowed) {
return { success: false, error: `Trop de tentatives. Réessayez dans ${formatRetryAfter(rl.retryAfterMs)}.` };
@@ -129,8 +130,8 @@ export async function loginAction(formData) {
const email = formData.get('email');
const password = formData.get('password');
const result = await login({ email, password });
const userAgent = h.get('user-agent') || null;
const result = await login({ email, password }, { ipAddress: ip !== 'unknown' ? ip : null, userAgent });
// An HttpOnly cookie is the only safe transport for session tokens; setting it
// here keeps the token out of any JavaScript-readable response payload.
@@ -322,6 +323,42 @@ export async function resetPasswordAction(formData) {
}
}
export async function setupAccountAction(formData) {
try {
const ip = await getClientIp();
const rl = enforceRateLimit(ip, 'setup_account');
if (rl && !rl.allowed) {
return { success: false, error: `Trop de tentatives. Réessayez dans ${formatRetryAfter(rl.retryAfterMs)}.` };
}
const email = formData.get('email');
const token = formData.get('token');
const newPassword = formData.get('newPassword');
const confirmPassword = formData.get('confirmPassword');
if (!newPassword || !confirmPassword) {
throw new UserFacingError('Les deux champs de mot de passe sont requis');
}
if (newPassword !== confirmPassword) {
throw new UserFacingError('Les mots de passe ne correspondent pas');
}
await completeAccountSetup({ email, token, password: newPassword });
return {
success: true,
message: 'Mot de passe créé avec succès. Vous pouvez maintenant vous connecter.'
};
} catch (error) {
if (error instanceof UserFacingError) {
return { success: false, error: error.message };
}
fail(`Auth: setupAccountAction error: ${error.message}`);
return { success: false, error: 'Une erreur interne est survenue. Veuillez réessayer.' };
}
}
export async function verifyEmailAction(formData) {
try {
const ip = await getClientIp();
+135 -19
View File
@@ -7,11 +7,12 @@
* the context argument: (request, params, { session }).
*/
import { query, updateById, findOne } from '@zen/core/database';
import { query, create, updateById, findOne } from '@zen/core/database';
import { updateUser, requestPasswordReset } from './auth.js';
import { hashPassword, verifyPassword } from '../../core/users/password.js';
import { createEmailChangeToken, verifyEmailChangeToken, applyEmailChange, sendEmailChangeConfirmEmail, sendEmailChangeOldNotifyEmail, sendEmailChangeNewNotifyEmail, sendPasswordChangedEmail, sendPasswordResetEmail } from './email.js';
import { listRoles, getRoleById, createRole, updateRole, deleteRole, getUserRoles, assignUserRole, revokeUserRole, deleteUserSessions } from '@zen/core/users';
import { hashPassword, verifyPassword, generateId } from '../../core/users/password.js';
import { createEmailChangeToken, verifyEmailChangeToken, applyEmailChange, sendEmailChangeConfirmEmail, sendEmailChangeOldNotifyEmail, sendEmailChangeNewNotifyEmail, sendPasswordChangedEmail, sendPasswordResetEmail, sendInvitationEmail } from './email.js';
import { createAccountSetup } from '../../core/users/verifications.js';
import { listRoles, getRoleById, createRole, updateRole, deleteRole, getUserRoles, assignUserRole, revokeUserRole, deleteUserSessions, PERMISSIONS, getRegisteredPermissions } from '@zen/core/users';
import { uploadImage, deleteFile, generateUniqueFilename, getFileExtension, FILE_TYPE_PRESETS, FILE_SIZE_LIMITS, validateUpload } from '@zen/core/storage';
import { getPublicBaseUrl } from '@zen/core/shared/config';
@@ -525,8 +526,26 @@ async function handleAssignUserRole(request, { id: userId }) {
// DELETE /zen/api/users/:id/roles/:roleId (admin only)
// ---------------------------------------------------------------------------
async function handleRevokeUserRole(_request, { id: userId, roleId }) {
async function handleRevokeUserRole(_request, { id: userId, roleId }, context) {
try {
if (context.session.user.id === userId) {
const roleHasPerm = await query(
`SELECT 1 FROM zen_auth_role_permissions WHERE role_id = $1 AND permission_key = $2`,
[roleId, PERMISSIONS.USERS_MANAGE]
);
if (roleHasPerm.rows.length > 0) {
const otherRoles = await query(
`SELECT 1 FROM zen_auth_user_roles ur
JOIN zen_auth_role_permissions rp ON rp.role_id = ur.role_id
WHERE ur.user_id = $1 AND rp.permission_key = $2 AND ur.role_id != $3
LIMIT 1`,
[userId, PERMISSIONS.USERS_MANAGE, roleId]
);
if (otherRoles.rows.length === 0) {
return apiError('Forbidden', "Vous ne pouvez pas retirer ce rôle car c'est votre seule source de la permission de gestion des utilisateurs.");
}
}
}
await revokeUserRole(userId, roleId);
return apiSuccess({ success: true });
} catch (error) {
@@ -544,6 +563,21 @@ async function handleListRoles() {
return apiSuccess({ roles });
}
// ---------------------------------------------------------------------------
// GET /zen/api/permissions (admin only)
// Catalogue dynamique : core + permissions enregistrées par les modules.
// ---------------------------------------------------------------------------
async function handleListPermissions() {
const permissions = getRegisteredPermissions();
const groups = permissions.reduce((acc, perm) => {
if (!acc[perm.group_name]) acc[perm.group_name] = [];
acc[perm.group_name].push(perm);
return acc;
}, {});
return apiSuccess({ permissions, groups });
}
// ---------------------------------------------------------------------------
// POST /zen/api/roles (admin only)
// ---------------------------------------------------------------------------
@@ -807,6 +841,86 @@ async function handleAdminSendPasswordReset(_request, { id: userId }) {
}
}
// ---------------------------------------------------------------------------
// POST /zen/api/users (admin only)
// ---------------------------------------------------------------------------
async function handleAdminCreateUser(request) {
try {
const body = await request.json();
const { name, email, password, roleIds } = body;
if (!name || typeof name !== 'string' || name.trim().length === 0) {
return apiError('Bad Request', 'Le nom est requis');
}
if (name.trim().length > 100) {
return apiError('Bad Request', 'Le nom doit contenir 100 caractères ou moins');
}
if (!email || !EMAIL_REGEX.test(email) || email.length > 254) {
return apiError('Bad Request', 'Adresse courriel invalide');
}
const normalizedEmail = email.trim().toLowerCase();
const existing = await findOne('zen_auth_users', { email: normalizedEmail });
if (existing) {
return apiError('Conflict', 'Cette adresse courriel est déjà utilisée');
}
const userId = generateId();
const hasPassword = typeof password === 'string' && password.length > 0;
const user = await create('zen_auth_users', {
id: userId,
email: normalizedEmail,
name: name.trim(),
email_verified: hasPassword,
image: null,
role: 'user',
updated_at: new Date()
});
if (hasPassword) {
const hashedPassword = await hashPassword(password);
await create('zen_auth_accounts', {
id: generateId(),
account_id: normalizedEmail,
provider_id: 'credential',
user_id: user.id,
password: hashedPassword,
updated_at: new Date()
});
} else {
const setup = await createAccountSetup(normalizedEmail);
const baseUrl = getPublicBaseUrl();
try {
await sendInvitationEmail(normalizedEmail, setup.token, baseUrl);
} catch (emailError) {
fail(`handleAdminCreateUser: failed to send invitation email to ${normalizedEmail}: ${emailError.message}`);
}
}
if (Array.isArray(roleIds) && roleIds.length > 0) {
for (const roleId of roleIds) {
if (typeof roleId === 'string' && roleId.length > 0) {
try {
await assignUserRole(user.id, roleId);
} catch (err) {
fail(`handleAdminCreateUser: failed to assign role ${roleId} to user ${user.id}: ${err.message}`);
}
}
}
}
return apiSuccess({ user, invited: !hasPassword });
} catch (error) {
logAndObscureError(error, null);
return apiError('Internal Server Error', 'Impossible de créer l\'utilisateur');
}
}
// ---------------------------------------------------------------------------
// Route definitions
// ---------------------------------------------------------------------------
@@ -815,7 +929,8 @@ async function handleAdminSendPasswordReset(_request, { id: userId }) {
// parameterised paths (/users/:id) so they match first.
export const routes = defineApiRoutes([
{ path: '/users', method: 'GET', handler: handleListUsers, auth: 'admin' },
{ path: '/users', method: 'GET', handler: handleListUsers, auth: 'admin', permission: PERMISSIONS.USERS_VIEW },
{ path: '/users', method: 'POST', handler: handleAdminCreateUser, auth: 'admin', permission: PERMISSIONS.USERS_MANAGE },
{ path: '/users/profile', method: 'PUT', handler: handleUpdateProfile, auth: 'user' },
{ path: '/users/profile/email', method: 'POST', handler: handleInitiateEmailChange, auth: 'user' },
{ path: '/users/profile/password', method: 'POST', handler: handleChangeOwnPassword, auth: 'user' },
@@ -825,17 +940,18 @@ export const routes = defineApiRoutes([
{ path: '/users/profile/sessions', method: 'DELETE', handler: handleDeleteAllSessions, auth: 'user' },
{ path: '/users/profile/sessions/:sessionId', method: 'DELETE', handler: handleDeleteSession, auth: 'user' },
{ path: '/users/email/confirm', method: 'GET', handler: handleConfirmEmailChange, auth: 'user' },
{ path: '/users/:id/roles', method: 'GET', handler: handleGetUserRoles, auth: 'admin' },
{ path: '/users/:id/roles', method: 'POST', handler: handleAssignUserRole, auth: 'admin' },
{ path: '/users/:id/roles/:roleId', method: 'DELETE', handler: handleRevokeUserRole, auth: 'admin' },
{ path: '/users/:id', method: 'GET', handler: handleGetUserById, auth: 'admin' },
{ path: '/users/:id', method: 'PUT', handler: handleUpdateUserById, auth: 'admin' },
{ path: '/users/:id/email', method: 'PUT', handler: handleAdminUpdateUserEmail, auth: 'admin' },
{ path: '/users/:id/password', method: 'PUT', handler: handleAdminSetUserPassword, auth: 'admin' },
{ path: '/users/:id/send-password-reset', method: 'POST', handler: handleAdminSendPasswordReset, auth: 'admin' },
{ path: '/roles', method: 'GET', handler: handleListRoles, auth: 'admin' },
{ path: '/roles', method: 'POST', handler: handleCreateRole, auth: 'admin' },
{ path: '/roles/:id', method: 'GET', handler: handleGetRole, auth: 'admin' },
{ path: '/roles/:id', method: 'PUT', handler: handleUpdateRole, auth: 'admin' },
{ path: '/roles/:id', method: 'DELETE', handler: handleDeleteRole, auth: 'admin' },
{ path: '/users/:id/roles', method: 'GET', handler: handleGetUserRoles, auth: 'admin', permission: PERMISSIONS.USERS_VIEW },
{ path: '/users/:id/roles', method: 'POST', handler: handleAssignUserRole, auth: 'admin', permission: PERMISSIONS.USERS_MANAGE },
{ path: '/users/:id/roles/:roleId', method: 'DELETE', handler: handleRevokeUserRole, auth: 'admin', permission: PERMISSIONS.USERS_MANAGE },
{ path: '/users/:id', method: 'GET', handler: handleGetUserById, auth: 'admin', permission: PERMISSIONS.USERS_VIEW },
{ path: '/users/:id', method: 'PUT', handler: handleUpdateUserById, auth: 'admin', permission: PERMISSIONS.USERS_MANAGE },
{ path: '/users/:id/email', method: 'PUT', handler: handleAdminUpdateUserEmail, auth: 'admin', permission: PERMISSIONS.USERS_MANAGE },
{ path: '/users/:id/password', method: 'PUT', handler: handleAdminSetUserPassword, auth: 'admin', permission: PERMISSIONS.USERS_MANAGE },
{ path: '/users/:id/send-password-reset', method: 'POST', handler: handleAdminSendPasswordReset, auth: 'admin', permission: PERMISSIONS.USERS_MANAGE },
{ path: '/roles', method: 'GET', handler: handleListRoles, auth: 'admin', permission: PERMISSIONS.ROLES_VIEW },
{ path: '/permissions', method: 'GET', handler: handleListPermissions, auth: 'admin', permission: PERMISSIONS.ROLES_VIEW },
{ path: '/roles', method: 'POST', handler: handleCreateRole, auth: 'admin', permission: PERMISSIONS.ROLES_MANAGE },
{ path: '/roles/:id', method: 'GET', handler: handleGetRole, auth: 'admin', permission: PERMISSIONS.ROLES_VIEW },
{ path: '/roles/:id', method: 'PUT', handler: handleUpdateRole, auth: 'admin', permission: PERMISSIONS.ROLES_MANAGE },
{ path: '/roles/:id', method: 'DELETE', handler: handleDeleteRole, auth: 'admin', permission: PERMISSIONS.ROLES_MANAGE },
]);
+3 -2
View File
@@ -4,7 +4,8 @@ import {
login,
requestPasswordReset,
verifyUserEmail,
updateUser
updateUser,
completeAccountSetup
} from '../../core/users/auth.js';
import { sendPasswordChangedEmail } from './email.js';
@@ -19,4 +20,4 @@ export function resetPassword(resetData) {
return _resetPassword(resetData, { onPasswordChanged: sendPasswordChangedEmail });
}
export { login, requestPasswordReset, verifyUserEmail, updateUser };
export { login, requestPasswordReset, verifyUserEmail, updateUser, completeAccountSetup };
+15 -1
View File
@@ -6,6 +6,7 @@ import { PasswordResetEmail } from './templates/PasswordResetEmail.js';
import { PasswordChangedEmail } from './templates/PasswordChangedEmail.js';
import { EmailChangeConfirmEmail } from './templates/EmailChangeConfirmEmail.js';
import { EmailChangeNotifyEmail } from './templates/EmailChangeNotifyEmail.js';
import { InvitationEmail } from './templates/InvitationEmail.js';
export { createEmailVerification, verifyEmailToken, createPasswordReset, verifyResetToken, deleteResetToken }
from '../../core/users/verifications.js';
@@ -93,4 +94,17 @@ async function sendEmailChangeNewNotifyEmail(newEmail, oldEmail) {
return result;
}
export { sendVerificationEmail, sendPasswordResetEmail, sendPasswordChangedEmail, sendEmailChangeConfirmEmail, sendEmailChangeOldNotifyEmail, sendEmailChangeNewNotifyEmail };
async function sendInvitationEmail(email, token, baseUrl) {
const appName = process.env.ZEN_NAME || 'ZEN';
const setupUrl = `${baseUrl}/auth/setup?email=${encodeURIComponent(email)}&token=${token}`;
const html = await render(<InvitationEmail setupUrl={setupUrl} companyName={appName} />);
const result = await sendEmail({ to: email, subject: `Terminez la création de votre compte ${appName}`, html });
if (!result.success) {
fail(`Auth: failed to send invitation email to ${email}: ${result.error}`);
throw new Error('Failed to send invitation email');
}
info(`Auth: invitation email sent to ${email}`);
return result;
}
export { sendVerificationEmail, sendPasswordResetEmail, sendPasswordChangedEmail, sendEmailChangeConfirmEmail, sendEmailChangeOldNotifyEmail, sendEmailChangeNewNotifyEmail, sendInvitationEmail };
+5 -2
View File
@@ -9,7 +9,8 @@ export {
requestPasswordReset,
resetPassword,
verifyUserEmail,
updateUser
updateUser,
completeAccountSetup
} from './auth.js';
export {
@@ -28,7 +29,8 @@ export {
deleteResetToken,
sendVerificationEmail,
sendPasswordResetEmail,
sendPasswordChangedEmail
sendPasswordChangedEmail,
sendInvitationEmail
} from './email.js';
export {
@@ -46,6 +48,7 @@ export {
forgotPasswordAction,
resetPasswordAction,
verifyEmailAction,
setupAccountAction,
setSessionCookie,
refreshSessionCookie
} from './actions.js';
@@ -80,7 +80,7 @@ export default function ConfirmEmailPage({ onSubmit, onNavigate, email, token })
console.log('ConfirmEmailPage render', { success, error, isLoading, hasVerified });
return (
<Card variant="default" padding="md" spacing="none" className="w-full max-w-md">
<Card variant="default" padding="md" spacing="none" className="w-full max-w-md mx-auto">
<AuthPageHeader title="Vérification de l'e-mail" description="Nous vérifions votre adresse e-mail..." />
{isLoading && (
@@ -45,7 +45,7 @@ export default function ForgotPasswordPage({ onSubmit, onNavigate, currentUser =
}
return (
<Card variant="default" padding="md" spacing="none" className="w-full max-w-md">
<Card variant="default" padding="md" spacing="none" className="w-full max-w-md mx-auto">
<AuthPageHeader title="Mot de passe oublié" description="Entrez votre adresse e-mail et nous vous enverrons un lien pour réinitialiser votre mot de passe." />
{currentUser && (
+1 -1
View File
@@ -67,7 +67,7 @@ export default function LoginPage({ onSubmit, onNavigate, onSetSessionCookie, re
};
return (
<Card variant="default" padding="md" spacing="none" className="w-full max-w-md">
<Card variant="default" padding="md" spacing="none" className="w-full max-w-md mx-auto">
<AuthPageHeader title="Connexion" description="Veuillez vous connecter pour continuer." />
{currentUser && (
+1 -1
View File
@@ -42,7 +42,7 @@ export default function LogoutPage({ onLogout, onSetSessionCookie }) {
};
return (
<Card variant="default" padding="md" spacing="none" className="w-full max-w-md">
<Card variant="default" padding="md" spacing="none" className="w-full max-w-md mx-auto">
<AuthPageHeader title="Prêt à vous déconnecter ?" description="Cela mettra fin à votre session et vous déconnectera de votre compte." />
{success && (
@@ -97,7 +97,7 @@ export default function RegisterPage({ onSubmit, onNavigate, currentUser = null
}
return (
<Card variant="default" padding="md" spacing="none" className="w-full max-w-md">
<Card variant="default" padding="md" spacing="none" className="w-full max-w-md mx-auto">
<AuthPageHeader title="Créer un compte" description="Inscrivez-vous pour commencer." />
{currentUser && (
@@ -65,7 +65,7 @@ export default function ResetPasswordPage({ onSubmit, onNavigate, email, token }
}
return (
<Card variant="default" padding="md" spacing="none" className="w-full max-w-md">
<Card variant="default" padding="md" spacing="none" className="w-full max-w-md mx-auto">
<AuthPageHeader title="Réinitialiser le mot de passe" description="Saisissez votre nouveau mot de passe ci-dessous." />
{error && !success && (
@@ -0,0 +1,149 @@
'use client';
import { useState } from 'react';
import { Card, Input, Button, PasswordStrengthIndicator } from '@zen/core/shared/components';
import AuthPageHeader from '../components/AuthPageHeader.js';
export default function SetupAccountPage({ onSubmit, onNavigate, email, token }) {
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [success, setSuccess] = useState('');
const [formData, setFormData] = useState({ newPassword: '', confirmPassword: '' });
const validatePassword = (password) => {
const errors = [];
if (password.length < 8) errors.push('Le mot de passe doit contenir au moins 8 caractères');
if (password.length > 128) errors.push('Le mot de passe doit contenir 128 caractères ou moins');
if (!/[A-Z]/.test(password)) errors.push('Le mot de passe doit contenir au moins une majuscule');
if (!/[a-z]/.test(password)) errors.push('Le mot de passe doit contenir au moins une minuscule');
if (!/\d/.test(password)) errors.push('Le mot de passe doit contenir au moins un chiffre');
return errors;
};
const isFormValid = () => {
return validatePassword(formData.newPassword).length === 0 &&
formData.newPassword === formData.confirmPassword &&
formData.newPassword.length > 0;
};
async function handleSubmit(e) {
e.preventDefault();
setError('');
setSuccess('');
setIsLoading(true);
const passwordErrors = validatePassword(formData.newPassword);
if (passwordErrors.length > 0) { setError(passwordErrors[0]); setIsLoading(false); return; }
if (formData.newPassword !== formData.confirmPassword) {
setError('Les mots de passe ne correspondent pas');
setIsLoading(false);
return;
}
const submitData = new FormData();
submitData.append('newPassword', formData.newPassword);
submitData.append('confirmPassword', formData.confirmPassword);
submitData.append('email', email);
submitData.append('token', token);
try {
const result = await onSubmit(submitData);
if (result.success) {
setSuccess(result.message);
setIsLoading(false);
setTimeout(() => onNavigate('login'), 2000);
} else {
setError(result.error || 'Impossible de créer le mot de passe');
setIsLoading(false);
}
} catch (err) {
console.error('Setup account error:', err);
setError('Une erreur inattendue s\'est produite');
setIsLoading(false);
}
}
return (
<Card variant="default" padding="md" spacing="none" className="w-full max-w-md mx-auto">
<AuthPageHeader
title="Créez votre mot de passe"
description="Un administrateur a créé votre compte. Choisissez un mot de passe pour y accéder."
/>
{error && !success && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg dark:bg-red-500/10 dark:border-red-500/20">
<div className="flex items-center space-x-2">
<div className="w-1.5 h-1.5 bg-red-500 rounded-full flex-shrink-0"></div>
<span className="text-xs text-red-700 dark:text-red-400">{error}</span>
</div>
</div>
)}
{success && (
<div className="mb-4 p-3 bg-green-50 border border-green-200 rounded-lg dark:bg-green-500/10 dark:border-green-500/20">
<div className="flex items-center space-x-2">
<div className="w-1.5 h-1.5 bg-green-500 rounded-full flex-shrink-0"></div>
<span className="text-xs text-green-700 dark:text-green-400">{success}</span>
</div>
</div>
)}
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<div>
<Input
id="newPassword"
name="newPassword"
type="password"
label="Mot de passe"
value={formData.newPassword}
onChange={(value) => setFormData(prev => ({ ...prev, newPassword: value }))}
placeholder="••••••••"
disabled={!!success}
minLength="8"
maxLength="128"
autoComplete="new-password"
required
/>
<PasswordStrengthIndicator password={formData.newPassword} showRequirements={true} />
</div>
<Input
id="confirmPassword"
name="confirmPassword"
type="password"
label="Confirmer le mot de passe"
value={formData.confirmPassword}
onChange={(value) => setFormData(prev => ({ ...prev, confirmPassword: value }))}
placeholder="••••••••"
disabled={!!success}
minLength="8"
maxLength="128"
autoComplete="new-password"
required
/>
<Button
type="submit"
variant="primary"
size="lg"
loading={isLoading}
disabled={!!success || !isFormValid()}
className="w-full mt-2"
>
Créer mon mot de passe
</Button>
</form>
<div className="mt-6 flex justify-center">
<Button
type="button"
variant="fullghost"
onClick={() => onNavigate('login')}
>
Retour à la connexion
</Button>
</div>
</Card>
);
}
+1
View File
@@ -4,3 +4,4 @@ export { default as ForgotPasswordPage } from './ForgotPasswordPage.client.js';
export { default as ResetPasswordPage } from './ResetPasswordPage.client.js';
export { default as ConfirmEmailPage } from './ConfirmEmailPage.client.js';
export { default as LogoutPage } from './LogoutPage.client.js';
export { default as SetupAccountPage } from './SetupAccountPage.client.js';
@@ -32,7 +32,7 @@ export const EmailChangeConfirmEmail = ({ confirmUrl, newEmail, companyName }) =
</Section>
<Text className="text-[12px] leading-[20px] text-neutral-400 m-0">
Ce lien expire dans 24 heures. Si vous n'êtes pas à l'origine de cette demande, ignorez ce message — votre adresse actuelle restera inchangée.
Ce lien expire dans 24 heures. Si vous n'êtes pas à l'origine de cette demande, ignorez ce message. Votre adresse actuelle reste inchangée.
</Text>
<Text className="text-[12px] leading-[20px] text-neutral-400 m-0 mt-[8px]">
@@ -6,19 +6,19 @@ const VARIANTS = {
preview: (name) => `Demande de modification de courriel ${name}`,
title: 'Demande de modification de courriel',
body: (name) => `Une demande de modification de l'adresse courriel associée à votre compte ${name} a été initiée.`,
note: "Si vous n'êtes pas à l'origine de cette demande, contactez immédiatement notre équipe de support. Votre adresse actuelle reste active jusqu'à confirmation.",
note: "Si vous n'êtes pas à l'origine de cette demande, contactez le support immédiatement. Votre adresse actuelle reste active jusqu'à confirmation.",
},
changed: {
preview: (name) => `Votre adresse courriel a été modifiée ${name}`,
title: 'Adresse courriel modifiée',
body: (name) => `L'adresse courriel de votre compte ${name} a été modifiée par un administrateur.`,
note: "Si vous n'êtes pas à l'origine de cette modification, contactez immédiatement notre équipe de support.",
note: "Si vous n'êtes pas à l'origine de cette modification, contactez le support immédiatement.",
},
admin_new: {
preview: (name) => `Votre compte est maintenant associé à cette adresse ${name}`,
title: 'Adresse courriel associée à votre compte',
body: (name) => `Votre adresse courriel est maintenant associée à un compte ${name}. Cette modification a été effectuée par un administrateur.`,
note: "Si vous n'avez pas été informé de cette modification, contactez notre équipe de support.",
note: "Si vous n'avez pas été informé de cette modification, contactez le support.",
},
};
@@ -0,0 +1,35 @@
import { Button, Section, Text, Link } from "@react-email/components";
import { BaseLayout } from "@zen/core/email/templates";
export const InvitationEmail = ({ setupUrl, companyName }) => (
<BaseLayout
preview={`Terminez la création de votre compte ${companyName}`}
title="Créez votre mot de passe"
companyName={companyName}
supportSection={true}
>
<Text className="text-[14px] leading-[24px] text-neutral-600 mt-[4px] mb-[24px]">
Un administrateur a créé un compte pour vous sur <span className="font-medium text-neutral-900">{companyName}</span>. Cliquez sur le bouton ci-dessous pour choisir votre mot de passe et accéder à votre compte.
</Text>
<Section className="mt-[28px] mb-[32px]">
<Button
href={setupUrl}
className="bg-neutral-900 rounded-[8px] text-white font-medium px-[20px] py-[11px] no-underline text-[13px]"
>
Créer mon mot de passe
</Button>
</Section>
<Text className="text-[12px] leading-[20px] text-neutral-400 m-0">
Ce lien expire dans 48 heures. Si vous n'attendiez pas cette invitation, ignorez ce message.
</Text>
<Text className="text-[12px] leading-[20px] text-neutral-400 m-0 mt-[8px]">
Lien :{' '}
<Link href={setupUrl} className="text-neutral-400 underline break-all">
{setupUrl}
</Link>
</Text>
</BaseLayout>
);
@@ -9,7 +9,7 @@ export const PasswordChangedEmail = ({ email, companyName }) => (
supportSection={true}
>
<Text className="text-[14px] leading-[24px] text-neutral-600 mt-[4px] mb-[24px]">
Ceci confirme que le mot de passe de votre compte <span className="font-medium text-neutral-900">{companyName}</span> a bien été modifié.
Le mot de passe associé au compte <span className="font-medium text-neutral-900">{companyName}</span> a été modifié.
</Text>
<Section style={{ border: '1px solid #E5E5E5' }} className="bg-neutral-100 rounded-[12px] p-[20px] my-[24px]">
@@ -22,7 +22,7 @@ export const PasswordChangedEmail = ({ email, companyName }) => (
</Section>
<Text className="text-[12px] leading-[20px] text-neutral-400 m-0">
Si vous n'êtes pas à l'origine de cette modification, contactez immédiatement notre équipe de support.
Si vous n'êtes pas à l'origine de cette modification, contactez le support immédiatement.
</Text>
</BaseLayout>
);
@@ -9,7 +9,7 @@ export const PasswordResetEmail = ({ resetUrl, companyName }) => (
supportSection={true}
>
<Text className="text-[14px] leading-[24px] text-neutral-600 mt-[4px] mb-[24px]">
Nous avons reçu une demande de réinitialisation du mot de passe pour votre compte <span className="font-medium text-neutral-900">{companyName}</span>. Cliquez sur le bouton ci-dessous pour en choisir un nouveau.
Une demande de réinitialisation du mot de passe a été reçue pour le compte <span className="font-medium text-neutral-900">{companyName}</span>. Cliquez sur le bouton pour en choisir un nouveau.
</Text>
<Section className="mt-[28px] mb-[32px]">
@@ -22,7 +22,7 @@ export const PasswordResetEmail = ({ resetUrl, companyName }) => (
</Section>
<Text className="text-[12px] leading-[20px] text-neutral-400 m-0">
Ce lien expire dans 1 heure. Si vous n'êtes pas à l'origine de cette demande, ignorez ce message votre mot de passe ne sera pas modifié.
Ce lien expire dans 1 heure. Si vous n'êtes pas à l'origine de cette demande, ignorez ce message. Votre mot de passe ne sera pas modifié.
</Text>
<Text className="text-[12px] leading-[20px] text-neutral-400 m-0 mt-[8px]">
@@ -9,7 +9,7 @@ export const VerificationEmail = ({ verificationUrl, companyName }) => (
supportSection={true}
>
<Text className="text-[14px] leading-[24px] text-neutral-600 mt-[4px] mb-[24px]">
Merci de vous être inscrit sur <span className="font-medium text-neutral-900">{companyName}</span>. Cliquez sur le bouton ci-dessous pour confirmer votre adresse courriel.
Confirmez votre adresse courriel pour accéder à votre compte <span className="font-medium text-neutral-900">{companyName}</span>.
</Text>
<Section className="mt-[28px] mb-[32px]">
+59 -7
View File
@@ -1,25 +1,66 @@
/**
* Core Feature Database Initialization (CLI)
* Database initialization for features and modules.
*
* Initialise et supprime les tables des features core. La liste est aujourd'hui
* limitée à auth — le seul feature avec un schéma. Ajouter ici si un autre
* feature gagne un db.js avec createTables()/dropTables().
* - Features core : auth (et tout futur core ayant un db.js).
* - Modules externes : découverts via discoverModules() ; chaque module
* exporte ses propres createTables/dropTables.
*
* Les permissions ajoutées par les modules doivent être enregistrées AVANT
* le seed de la BD pour qu'elles soient persistées et auto-attribuées au
* rôle admin. C'est pour cela qu'on appelle register() de chaque module
* avant initFeatures().
*/
import { createTables as authCreate, dropTables as authDrop } from './auth/db.js';
import { done, fail, info, step } from '@zen/core/shared/logger';
import { discoverModules, validateModuleEnvVars } from '../core/modules/discover.server.js';
import { getRegisteredModules } from '../core/modules/registry.js';
import { registerPermissions } from '../core/users/permissions-registry.js';
const FEATURES = [
const CORE_FEATURES = [
{ name: 'auth', createTables: authCreate, dropTables: authDrop },
];
async function loadModules() {
await discoverModules();
const modules = getRegisteredModules();
validateModuleEnvVars(modules);
// Enregistre les permissions du module et exécute son register() pour que
// tous les hooks runtime soient en place avant le seed.
for (const mod of modules) {
if (Array.isArray(mod.manifest?.permissions)) {
registerPermissions(mod.manifest.permissions);
}
if (typeof mod.register === 'function') {
try {
await mod.register();
} catch (error) {
fail(`zen-modules: ${mod.manifest.name} register() threw — ${error.message}`);
}
}
}
return modules;
}
export async function initFeatures() {
const created = [];
const skipped = [];
step('Initializing feature databases...');
for (const { name, createTables } of FEATURES) {
// Charger les modules d'abord pour que leurs permissions soient connues
// au moment du seed (et donc auto-attribuées au rôle admin).
const modules = await loadModules();
const targets = [
...CORE_FEATURES,
...modules
.filter(m => typeof m.createTables === 'function')
.map(m => ({ name: m.manifest.name, createTables: m.createTables, dropTables: m.dropTables })),
];
for (const { name, createTables } of targets) {
try {
step(`Initializing ${name}...`);
if (typeof createTables !== 'function') {
@@ -40,7 +81,18 @@ export async function initFeatures() {
}
export async function dropFeatures() {
for (const { name, dropTables } of [...FEATURES].reverse()) {
const modules = await loadModules();
// Ordre de création : core, puis modules. Drop = ordre inverse pour que
// les tables modules (qui peuvent avoir des FK vers core) tombent d'abord.
const targets = [
...CORE_FEATURES,
...modules
.filter(m => typeof m.dropTables === 'function')
.map(m => ({ name: m.manifest.name, dropTables: m.dropTables })),
];
for (const { name, dropTables } of [...targets].reverse()) {
try {
if (typeof dropTables !== 'function') {
info(`${name} has no dropTables function`);
+1 -1
View File
@@ -27,7 +27,7 @@ export * as pdf from "./core/pdf/index.js";
// Do not export here to avoid mixing client/server boundaries
// Export app configuration utilities
export { getAppName, getAppConfig, getSessionCookieName, getPublicBaseUrl } from "./shared/lib/appConfig.js";
export { getAppName, getAppConfig, getSessionCookieName, getPublicBaseUrl, isDevkitEnabled } from "./shared/lib/appConfig.js";
// Export initialization utilities
export { initializeZen, resetZenInitialization } from "./shared/lib/init.js";
+3 -3
View File
@@ -20,11 +20,11 @@ const Button = ({
const variants = {
primary: 'bg-neutral-900 text-white hover:bg-neutral-800 focus:ring-neutral-900/20 dark:bg-white dark:text-black dark:hover:bg-neutral-100 dark:focus:ring-white/20',
secondary: 'bg-transparent border border-neutral-300 text-neutral-700 hover:bg-neutral-100 focus:ring-neutral-500/20 dark:bg-neutral-800/60 dark:border-neutral-700/50 dark:text-white dark:hover:bg-neutral-800/80 dark:focus:ring-neutral-600/20',
danger: 'bg-red-700/10 border border-red-800/30 text-red-700 hover:bg-red-700/15 focus:ring-red-700/20 dark:bg-red-700/20 dark:border-red-600/20 dark:text-red-600 dark:hover:bg-red-600/30 dark:focus:ring-red-600/20',
danger: 'bg-red-700/10 border border-red-800/30 text-red-700 hover:bg-red-700/15 focus:ring-red-700/20 dark:bg-red-700/10 dark:border-red-600/20 dark:text-red-600 dark:hover:bg-red-600/20 dark:focus:ring-red-600/20',
ghost: 'text-neutral-600 hover:text-neutral-900 hover:bg-neutral-100 focus:ring-neutral-500/20 dark:text-neutral-400 dark:hover:text-white dark:hover:bg-neutral-700/30 dark:focus:ring-neutral-600/20',
fullghost: 'text-neutral-600 hover:text-neutral-900 focus:[box-shadow:none] dark:text-neutral-400 dark:hover:text-white',
success: 'bg-green-700/10 border border-green-800/30 text-green-700 hover:bg-green-700/15 focus:ring-green-700/20 dark:bg-green-700/20 dark:border-green-600/20 dark:text-green-600 dark:hover:bg-green-600/30 dark:focus:ring-green-600/20',
warning: 'bg-yellow-700/10 border border-yellow-800/30 text-yellow-700 hover:bg-yellow-700/15 focus:ring-yellow-700/20 dark:bg-yellow-700/20 dark:border-yellow-600/20 dark:text-yellow-600 dark:hover:bg-yellow-600/30 dark:focus:ring-yellow-600/20'
success: 'bg-green-700/10 border border-green-800/30 text-green-700 hover:bg-green-700/15 focus:ring-green-700/20 dark:bg-green-700/10 dark:border-green-600/20 dark:text-green-600 dark:hover:bg-green-600/20 dark:focus:ring-green-600/20',
warning: 'bg-yellow-700/10 border border-yellow-800/30 text-yellow-700 hover:bg-yellow-700/15 focus:ring-yellow-700/20 dark:bg-yellow-700/10 dark:border-yellow-600/20 dark:text-yellow-600 dark:hover:bg-yellow-600/20 dark:focus:ring-yellow-600/20'
};
const sizes = {
+2 -5
View File
@@ -1,6 +1,7 @@
'use client';
import { useState, useRef, useEffect } from 'react';
import { Tick02Icon } from '@zen/core/shared/icons';
const ROW1 = ['#4489ed', '#2a9db0', '#43b53c', '#5e7b4e', '#f5c211', '#f7581f', '#ff2b2b', '#ff2e63', '#f540ed', '#b34ce9', '#818faf', '#c0bfbc'];
const ROW2 = ['#2657cf', '#24687a', '#287124', '#384d2f', '#c68408', '#c0280e', '#ca0505', '#ce0245', '#b417a7', '#8021aa', '#4e5b7e', '#75746f'];
@@ -9,11 +10,7 @@ const PRESET_COLORS = [...ROW1, ...ROW2, ...ROW3];
const isValidHex = (hex) => /^#[0-9a-fA-F]{6}$/.test(hex);
const Checkmark = () => (
<svg className="w-4 h-4 text-white drop-shadow-sm" fill="none" viewBox="0 0 24 24" strokeWidth={3} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="m4.5 12.75 6 6 9-13.5" />
</svg>
);
const Checkmark = () => <Tick02Icon className="w-4 h-4 text-white drop-shadow-sm" />;
const ColorPicker = ({
value,
+8 -8
View File
@@ -1,21 +1,21 @@
'use client';
import React from 'react';
import { Recycle03Icon } from '../icons/index.js';
const Loading = ({ size = 'md' }) => {
const sizes = {
sm: 'w-6 h-6',
md: 'w-10 h-10',
lg: 'w-16 h-16'
sm: 'w-5 h-5',
md: 'w-8 h-8',
lg: 'w-12 h-12'
};
return (
<div className="flex flex-col items-center justify-center gap-3 text-neutral-600 dark:text-neutral-400">
<div className="animate-spin">
<Recycle03Icon className={sizes[size]} />
</div>
<p className="text-sm">Loading....</p>
<svg className={`${sizes[size]} animate-spin`} viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="2" strokeOpacity="0.15" />
<path d="M12 2 a10 10 0 0 1 10 10" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
<p className="text-sm">Chargement</p>
</div>
);
};
+2 -3
View File
@@ -1,17 +1,16 @@
'use client';
import React from 'react';
const TabNav = ({ tabs = [], activeTab, onTabChange}) => {
return (
<div className="w-full flex border-b border-neutral-200 dark:border-neutral-800/70">
<div className="w-full flex overflow-x-auto border-b border-neutral-200 dark:border-neutral-800/70 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
{tabs.map((tab) => {
const isActive = tab.id === activeTab;
return (
<button
key={tab.id}
onClick={() => onTabChange(tab.id)}
className={`cursor-pointer px-4 py-2.5 text-[13px] font-medium transition-colors duration-[120ms] ease-out border-b-2 -mb-px ${
className={`shrink-0 whitespace-nowrap cursor-pointer px-4 py-2.5 text-[13px] font-medium transition-colors duration-[120ms] ease-out border-b-2 -mb-px ${
isActive
? 'border-neutral-900 dark:border-white text-neutral-900 dark:text-white'
: 'border-transparent text-neutral-500 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white'
+46 -48
View File
@@ -3,7 +3,7 @@
import React from 'react';
import Badge from './Badge';
import Button from './Button';
import { TorriGateIcon } from '../icons/index.js';
import { TorriGateIcon, ArrowDown01Icon } from '@zen/core/shared/icons';
const ROW_SIZE = {
sm: { cell: 'px-4 py-[11px]', header: 'px-4 py-[9px]', mobile: 'p-4' },
@@ -41,13 +41,7 @@ const Table = ({
const isDesc = isActive && sortOrder === 'desc';
return (
<span className="ml-1">
<svg
className={`w-4 h-4 transition-transform ${isActive ? (isDesc ? 'rotate-180' : '') : 'text-neutral-500 dark:text-neutral-400'}`}
fill="currentColor"
viewBox="0 0 20 20"
>
<path fillRule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clipRule="evenodd" />
</svg>
<ArrowDown01Icon className={`w-4 h-4 transition-transform ${isActive ? (isDesc ? 'rotate-180' : '') : 'text-neutral-500 dark:text-neutral-400'}`} />
</span>
);
};
@@ -101,49 +95,53 @@ const Table = ({
return value || '-';
};
const MobileCard = ({ item }) => (
<div className={`${sizeClasses.mobile} space-y-3`}>
<div className="flex items-start justify-between">
<div className="flex-1">
{columns.slice(0, 2).map((column) => (
<div key={column.key} className={column.key === columns[0]?.key ? 'mb-2' : ''}>
{renderCellContent(item, column)}
</div>
))}
</div>
<div className="flex flex-col gap-2 ml-4">
{columns.slice(2, 4).map((column) => (
<div key={column.key}>{renderCellContent(item, column)}</div>
))}
</div>
const MobileCard = ({ item }) => {
const visible = columns.filter((col) => !col.mobileHidden);
const [primary, ...rest] = visible;
return (
<div className={`${sizeClasses.mobile} space-y-2.5`}>
{primary && (
<div className="text-sm font-medium text-neutral-900 dark:text-white">
{renderCellContent(item, primary)}
</div>
)}
{rest.length > 0 && (
<dl className="grid grid-cols-2 gap-x-4 gap-y-2.5">
{rest.map((column) => (
<div key={column.key}>
<dt className="text-[11px] font-medium uppercase tracking-[0.04em] text-neutral-400 dark:text-neutral-500">
{column.label}
</dt>
<dd className="mt-0.5 text-xs text-neutral-700 dark:text-neutral-300">
{renderCellContent(item, column)}
</dd>
</div>
))}
</dl>
)}
</div>
{columns.length > 4 && (
<div className="text-xs text-neutral-500 dark:text-neutral-400">
{columns.slice(4).map((column) => (
<div key={column.key} className="mb-1">
<span className="font-medium">{column.label}:</span> {renderCellContent(item, column)}
</div>
))}
</div>
)}
</div>
);
);
};
const MobileSkeletonCard = () => (
<div className={`${sizeClasses.mobile} space-y-3`}>
<div className="flex items-start justify-between">
<div className="space-y-2 flex-1">
<Skeleton height="h-4" width="70%" />
<Skeleton height="h-3" width="40%" />
</div>
<div className="flex flex-col gap-2 ml-4">
<Skeleton cx="rounded-full" height="h-6" width="80px" />
<Skeleton cx="rounded-full" height="h-6" width="90px" />
</div>
const MobileSkeletonCard = () => {
const visibleCount = columns.filter((col) => !col.mobileHidden).length;
const restCount = Math.min(Math.max(0, visibleCount - 1), 4);
return (
<div className={`${sizeClasses.mobile} space-y-2.5`}>
<Skeleton height="h-4" width="55%" />
{restCount > 0 && (
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
{Array.from({ length: restCount }).map((_, i) => (
<div key={i} className="space-y-1">
<Skeleton height="h-2.5" width="40%" />
<Skeleton height="h-3.5" width="70%" />
</div>
))}
</div>
)}
</div>
<Skeleton height="h-3" width="50%" />
</div>
);
);
};
const getPageNumbers = () => {
const pages = [];
+57 -91
View File
@@ -1,29 +1,33 @@
export const ChevronDownIcon = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width={16} height={16} color={"currentColor"} fill={"none"} {...props}>
<path d="M18 9.00005C18 9.00005 13.5811 15 12 15C10.4188 15 6 9 6 9" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"></path>
</svg>
export const ArrowDown01Icon = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width={64} height={64} color={"currentColor"} fill={"none"} {...props}>
<path d="M5.99977 9.00005L11.9998 15L17.9998 9" stroke="currentColor" strokeWidth="2" strokeMiterlimit="16" strokeLinecap="round" strokeLinejoin="round"></path>
</svg>
);
export const ChevronRightIcon = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width={16} height={16} color={"currentColor"} fill={"none"} {...props}>
<path d="M9 6C9 6 15 10.4189 15 12C15 13.5812 9 18 9 18" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"></path>
</svg>
export const ArrowLeft01Icon = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width={64} height={64} color={"currentColor"} fill={"none"} {...props}>
<path d="M15 6L9 12.0001L15 18" stroke="currentColor" strokeWidth="2" strokeMiterlimit="16" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
export const ArrowRight01Icon = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width={64} height={64} color={"currentColor"} fill={"none"} {...props}>
<path d="M9.00005 6L15 12L9 18" stroke="currentColor" strokeWidth="2" strokeMiterlimit="16" strokeLinecap="round" strokeLinejoin="round"></path>
</svg>
);
export const ArrowUp01Icon = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width={64} height={64} color={"currentColor"} fill={"none"} {...props}>
<path d="M18 15L12 9L6 15" stroke="currentColor" strokeWidth="2" strokeMiterlimit="16" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
export const UserCircle02Icon = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width={24} height={24} color={"currentColor"} fill={"none"} {...props}>
<path d="M22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12Z" stroke="currentColor" strokeWidth="1.5"></path>
<path d="M14.75 9.5C14.75 11.0188 13.5188 12.25 12 12.25C10.4812 12.25 9.25 11.0188 9.25 9.5C9.25 7.98122 10.4812 6.75 12 6.75C13.5188 6.75 14.75 7.98122 14.75 9.5Z" stroke="currentColor" strokeWidth="1.5"></path>
<path d="M5.49994 19.0001L6.06034 18.0194C6.95055 16.4616 8.60727 15.5001 10.4016 15.5001H13.5983C15.3926 15.5001 17.0493 16.4616 17.9395 18.0194L18.4999 19.0001" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"></path>
</svg>
);
export const ManagerIcon = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width={24} height={24} color={"currentColor"} fill={"none"} {...props}>
<path d="M7.948 12.25L8 12.25C8.29639 12.25 8.56499 12.4246 8.68536 12.6954L12 20.1533L15.3146 12.6954C15.435 12.4246 15.7036 12.25 16 12.25L16.052 12.25C16.9505 12.25 17.6997 12.2499 18.2945 12.3299C18.9223 12.4143 19.4891 12.6 19.9445 13.0555C20.4 13.5109 20.5857 14.0777 20.6701 14.7055C20.7501 15.3003 20.75 16.0495 20.75 16.948V18.7505C20.75 20.6361 20.75 21.5789 20.1642 22.1647C19.5784 22.7505 18.6356 22.7505 16.75 22.7505H7.25C5.36438 22.7505 4.42158 22.7505 3.83579 22.1647C3.25 21.5789 3.25 20.6361 3.25 18.7505V16.948C3.24997 16.0495 3.24995 15.3003 3.32991 14.7055C3.41432 14.0777 3.59999 13.5109 4.05546 13.0555C4.51093 12.6 5.07773 12.4143 5.70552 12.3299C6.3003 12.2499 7.04954 12.25 7.948 12.25Z" fill="currentColor"></path>
<path fillRule="evenodd" clipRule="evenodd" d="M10.362 12.6057C10.4987 12.3846 10.7401 12.25 11 12.25H13C13.2599 12.25 13.5013 12.3846 13.638 12.6057C13.7746 12.8268 13.7871 13.1029 13.6708 13.3354L12.7724 15.1323L13.2442 18.907C13.2581 19.0181 13.2469 19.1309 13.2115 19.2372L12.7115 20.7372C12.6094 21.0434 12.3228 21.25 12 21.25C11.6772 21.25 11.3906 21.0434 11.2885 20.7372L10.7885 19.2372C10.7531 19.1309 10.7419 19.0181 10.7558 18.907L11.2276 15.1323L10.3292 13.3354C10.2129 13.1029 10.2254 12.8268 10.362 12.6057Z" fill="currentColor"></path>
<path d="M7.75 5.5C7.75 3.15279 9.65279 1.25 12 1.25C14.3472 1.25 16.25 3.15279 16.25 5.5V6.5C16.25 8.84721 14.3472 10.75 12 10.75C9.65279 10.75 7.75 8.84721 7.75 6.5V5.5Z" fill="currentColor"></path>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width={64} height={64} color={"currentColor"} fill={"none"} {...props}>
<path fillRule="evenodd" clipRule="evenodd" d="M12 3.20455C7.1424 3.20455 3.20455 7.1424 3.20455 12C3.20455 16.8576 7.1424 20.7955 12 20.7955C16.8576 20.7955 20.7955 16.8576 20.7955 12C20.7955 7.1424 16.8576 3.20455 12 3.20455ZM1.25 12C1.25 6.06294 6.06294 1.25 12 1.25C17.9371 1.25 22.75 6.06294 22.75 12C22.75 17.9371 17.9371 22.75 12 22.75C6.06294 22.75 1.25 17.9371 1.25 12Z" fill="currentColor"></path>
<path d="M8.5 9.5C8.5 7.567 10.067 6 12 6C13.933 6 15.5 7.567 15.5 9.5C15.5 11.433 13.933 13 12 13C10.067 13 8.5 11.433 8.5 9.5Z" fill="currentColor"></path>
<path d="M5.40873 17.6472C6.43247 15.8556 8.3377 14.75 10.4011 14.75H13.5979C15.6613 14.75 17.5666 15.8556 18.5903 17.6472L19.6094 19.5928C17.6634 21.5432 14.9724 22.7499 11.9996 22.7499C9.0267 22.7499 6.33569 21.5431 4.38965 19.5928L5.40873 17.6472Z" fill="currentColor"></path>
</svg>
);
@@ -49,17 +53,6 @@ export const Ticket01Icon = (props) => (
</svg>
);
export const CodesandboxIcon = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width={24} height={24} color={"currentColor"} fill={"none"} {...props}>
<path d="M21.5854 12.7014C21.7755 12.6064 22 12.7434 22 12.9546V15.9274C22 16.2266 22 16.5051 21.9763 16.7409C21.9176 17.3235 21.6402 17.7932 21.1584 18.1311C20.9629 18.2682 20.7178 18.4066 20.4522 18.5566L17.8977 19.9998V15.5912C17.8977 15.2446 17.8985 15.0386 17.9133 14.8844C17.9267 14.7444 17.9424 14.6968 18.0573 14.618C18.1727 14.5391 18.3369 14.4495 18.627 14.2934L21.5854 12.7014Z" fill="currentColor"></path>
<path d="M2.41532 12.7014C2.22499 12.6063 2.00036 12.7435 2.00036 12.9548V15.9274C2.00031 16.4704 1.97931 17.0493 2.26565 17.5346C2.55197 18.0198 3.07189 18.2876 3.54797 18.5566L6.10302 20V15.5912C6.10302 15.2446 6.1022 15.0386 6.08739 14.8844C6.07395 14.7445 6.05837 14.6968 5.94334 14.618C5.82803 14.5391 5.66375 14.4495 5.37365 14.2934L2.41532 12.7014Z" fill="currentColor"></path>
<path d="M12.0002 1C11.4351 1 10.9393 1.30567 10.4645 1.57398L8.60423 2.62491C8.3137 2.78905 8.16843 2.87111 8.17114 2.99232C8.17384 3.11353 8.32262 3.18915 8.62018 3.34039L11.2033 4.73209C11.4998 4.8924 11.676 4.98679 11.8157 5.04608C11.9624 5.10835 12.0383 5.10835 12.185 5.04608C12.3247 4.98679 12.5009 4.8924 12.7974 4.73209L15.3803 3.34048C15.6779 3.18924 15.8267 3.11362 15.8294 2.99241C15.8321 2.8712 15.6868 2.78913 15.3963 2.625L13.5359 1.574C13.0611 1.30569 12.5653 1 12.0002 1Z" fill="currentColor"></path>
<path d="M3.75954 5.66484C3.52033 5.80109 3.40073 5.86922 3.39781 5.982C3.3978 5.98247 3.39779 5.98299 3.39778 5.98347C3.3957 6.09626 3.51387 6.17071 3.75022 6.31959L3.87864 6.40049L11.0404 10.3199C11.5101 10.5769 11.7449 10.7054 12.0006 10.7054C12.2563 10.7054 12.4911 10.5769 12.9608 10.3199L20.1286 6.3972L20.2485 6.32465C20.4918 6.17755 20.6134 6.104 20.6119 5.9895C20.6104 5.875 20.4874 5.80494 20.2414 5.66482L17.9916 4.38302C17.7617 4.25206 17.6468 4.18659 17.5203 4.18377C17.3938 4.18096 17.2761 4.24126 17.0407 4.36187L15.8491 4.97222L13.4658 6.27188C13.0101 6.52043 12.5361 6.80176 12.0007 6.80176C11.4653 6.80176 10.9913 6.52043 10.5356 6.27188L8.15225 4.97222L6.9605 4.36178C6.72505 4.24118 6.60732 4.18087 6.48084 4.18369C6.35437 4.1865 6.23944 4.25198 6.00958 4.38294L3.75954 5.66484Z" fill="currentColor"></path>
<path d="M21.7485 7.78253C21.7423 7.54014 21.7392 7.41895 21.6382 7.36368C21.5372 7.30841 21.4261 7.3756 21.2039 7.50997L20.8865 7.70194L20.8751 7.70871L20.8585 7.71805L13.7904 11.5861C13.2835 11.8635 13.0301 12.0022 12.8903 12.238C12.7506 12.4738 12.7506 12.7627 12.7506 13.3406V22.1456C12.7506 22.4207 12.7506 22.5582 12.8497 22.6161C12.9488 22.674 13.0644 22.6089 13.2954 22.4786C13.3631 22.4405 13.4304 22.4019 13.4978 22.3635L15.7457 21.0828C15.9921 20.9424 16.1153 20.8722 16.183 20.7557C16.2507 20.6392 16.2507 20.4975 16.2507 20.2139L16.2507 15.5751C16.2506 15.2749 16.2506 14.9987 16.2728 14.7662C16.327 14.197 16.5857 13.7281 17.0585 13.4016C17.2452 13.2726 17.4778 13.1464 17.7226 13.0136L20.6451 11.4276C20.6488 11.4256 20.6524 11.4236 20.6562 11.4217C20.8363 11.3278 21.0178 11.2363 21.1996 11.1455C21.4673 11.0117 21.6011 10.9448 21.6758 10.8239C21.7505 10.7031 21.7505 10.5535 21.7505 10.2543V8.25009C21.7505 8.0951 21.7524 7.93867 21.7485 7.78253Z" fill="currentColor"></path>
<path d="M10.2069 11.5932C10.7121 11.8615 10.9647 11.9956 11.1076 12.2334C11.2506 12.4712 11.2506 12.761 11.2506 13.3406V22.1457C11.2506 22.4208 11.2506 22.5583 11.1515 22.6162C11.0524 22.6741 10.9368 22.609 10.7058 22.4787C10.638 22.4405 10.5706 22.4019 10.5031 22.3634L8.25564 21.083C8.00926 20.9426 7.88608 20.8724 7.81837 20.7559C7.75066 20.6394 7.75066 20.4976 7.75066 20.2141L7.75067 15.5751C7.75069 15.2749 7.75071 14.9987 7.72856 14.7662C7.67434 14.197 7.41566 13.7281 6.94286 13.4016C6.7561 13.2726 6.52351 13.1464 6.27868 13.0136L3.35624 11.4276C3.17412 11.3287 2.98866 11.2352 2.80249 11.1431C2.53479 11.0106 2.40093 10.9444 2.32572 10.8232C2.2505 10.7021 2.2505 10.5528 2.2505 10.2541V8.25011C2.25049 8.09103 2.24842 7.93033 2.2528 7.76998C2.25946 7.52606 2.26279 7.4041 2.36529 7.34934C2.4678 7.29457 2.57859 7.36436 2.80017 7.50394L3.1031 7.69476C5.37221 9.12403 7.82653 10.329 10.2069 11.5932Z" fill="currentColor"></path>
</svg>
);
export const InboxIcon = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width={24} height={24} color={"currentColor"} fill={"none"} {...props}>
<path fillRule="evenodd" clipRule="evenodd" d="M12.8251 1.75C15.0007 1.74998 15.7354 1.74997 17.0955 1.93282C18.4999 2.12164 19.6537 2.52175 20.5661 3.43414C21.4785 4.34653 21.8786 5.50033 22.0674 6.90471C22.2313 8.12428 22.2483 10.047 22.25 12L22.2436 13.5037C22.235 15.2454 22.1957 16.6539 21.9907 17.7892C21.7817 18.9461 21.3902 19.8839 20.635 20.6391C19.7768 21.4973 18.6846 21.8843 17.3079 22.0694C15.9645 22.25 14.2438 22.25 12.0531 22.25H11.9387C9.74804 22.25 8.02737 22.25 6.68396 22.0694C5.3073 21.8843 4.21505 21.4973 3.35685 20.6391C2.60168 19.8839 2.21018 18.9461 2.00122 17.7892C1.79615 16.6539 1.75684 15.2454 1.74826 13.5037L1.7504 11.9999C1.75213 10.0469 1.76906 8.12426 1.93303 6.90471C2.12184 5.50033 2.52195 4.34653 3.43434 3.43414C4.34673 2.52175 5.50054 2.12164 6.90492 1.93282C8.26505 1.74996 9.99978 1.74998 12.1757 1.75H12.8251ZM20.0852 7.17121C20.2409 8.32894 20.2497 10.2983 20.2502 12.4499C20.2502 12.6155 20.1159 12.75 19.9502 12.75L16.5703 12.75C15.2901 12.75 14.4348 13.7898 14.0243 14.6123C13.7341 15.1938 13.1705 15.75 11.9959 15.75C10.8213 15.75 10.2578 15.1938 9.96755 14.6123C9.55706 13.7898 8.70178 12.75 7.42159 12.75L4.0502 12.75C3.88452 12.75 3.75019 12.6155 3.75023 12.4499C3.75071 10.2983 3.75954 8.32894 3.91519 7.17121C4.07419 5.9886 4.3697 5.3272 4.84855 4.84835C5.32741 4.3695 5.98881 4.07399 7.17141 3.91499C8.38278 3.75212 10.4828 3.75 12.7502 3.75C15.0176 3.75 15.6176 3.75212 16.829 3.91499C18.0116 4.07399 18.673 4.3695 19.1519 4.84835C19.6307 5.3272 19.9262 5.9886 20.0852 7.17121Z" fill="currentColor" />
@@ -72,12 +65,6 @@ export const Folder01Icon = (props) => (
</svg>
);
export const CouponPercentIcon = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width={24} height={24} color={"currentColor"} fill={"none"} {...props}>
<path fillRule="evenodd" clipRule="evenodd" d="M14.0534 2.75C15.6601 2.74999 16.9289 2.74998 17.9391 2.86271C18.9748 2.97828 19.8256 3.21963 20.5575 3.76216C21.0606 4.13512 21.4961 4.5968 21.8456 5.12508C22.4908 6.10053 22.6811 7.28307 22.749 8.8459C22.7814 9.59249 22.1524 10.0938 21.5352 10.0938C20.6033 10.0938 19.776 10.9064 19.776 12C19.776 13.0936 20.6033 13.9062 21.5352 13.9062C22.1524 13.9062 22.7814 14.4075 22.749 15.1541C22.6811 16.7169 22.4908 17.8995 21.8456 18.8749C21.4961 19.4032 21.0606 19.8649 20.5575 20.2378C19.8256 20.7804 18.9748 21.0217 17.9391 21.1373C16.9289 21.25 15.6601 21.25 14.0534 21.25H14.0533H9.94687H9.94678C8.34009 21.25 7.07132 21.25 6.06107 21.1373C5.02537 21.0217 4.17461 20.7804 3.44269 20.2378C2.93955 19.8649 2.50404 19.4032 2.15462 18.8749C1.50935 17.8994 1.31903 16.7167 1.2512 15.1536C1.21882 14.4074 1.84739 13.9062 2.46441 13.9062C3.39631 13.9062 4.2236 13.0936 4.2236 12C4.2236 10.9064 3.39631 10.0938 2.46441 10.0938C1.84739 10.0938 1.21882 9.5926 1.2512 8.84643C1.31903 7.28333 1.50935 6.10064 2.15462 5.12508C2.50404 4.5968 2.93954 4.13512 3.44269 3.76216C4.17461 3.21963 5.02537 2.97828 6.06107 2.86271C7.07132 2.74998 8.3401 2.74999 9.94682 2.75H9.94683H14.0534H14.0534ZM13.7931 8.79289C14.1837 8.40237 14.8168 8.40237 15.2074 8.79289C15.5979 9.18342 15.5979 9.81658 15.2074 10.2071L10.2074 15.2071C9.81683 15.5976 9.18366 15.5976 8.79314 15.2071C8.40261 14.8166 8.40261 14.1834 8.79314 13.7929L13.7931 8.79289ZM9.51147 8.5H9.50024C8.94796 8.5 8.50024 8.94772 8.50024 9.5C8.50024 10.0523 8.94796 10.5 9.50024 10.5H9.51147C10.0638 10.5 10.5115 10.0523 10.5115 9.5C10.5115 8.94772 10.0638 8.5 9.51147 8.5ZM14.489 13.5C13.9367 13.5 13.489 13.9477 13.489 14.5C13.489 15.0523 13.9367 15.5 14.489 15.5H14.5002C15.0525 15.5 15.5002 15.0523 15.5002 14.5C15.5002 13.9477 15.0525 13.5 14.5002 13.5H14.489Z" fill="currentColor"></path>
</svg>
);
export const Settings02Icon = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width={24} height={24} color={"currentColor"} fill={"none"} {...props}>
<path fillRule="evenodd" clipRule="evenodd" d="M8.70208 2.00968C8.41768 1.9716 8.17792 2.05387 8.01469 2.1251C7.86464 2.19058 7.69535 2.28616 7.53113 2.37889L5.28197 3.66739C5.13616 3.77054 4.93913 3.93475 4.82604 4.20047C4.70258 4.49056 4.74241 4.78294 4.77174 4.95206C4.80348 5.13505 4.86932 5.387 4.93233 5.62811C5.32857 7.14492 4.36065 8.77664 2.79378 9.19656L2.76618 9.20395C2.52912 9.26746 2.30699 9.32697 2.13327 9.3896C1.97074 9.4482 1.69912 9.55819 1.50772 9.80652C1.33182 10.0347 1.28519 10.2849 1.26684 10.4619C1.24992 10.6252 1.24996 10.8205 1.25 11.0105V12.9897C1.24996 13.1797 1.24992 13.375 1.26684 13.5383C1.28519 13.7153 1.33181 13.9654 1.5077 14.1936C1.69909 14.4419 1.97071 14.5519 2.13322 14.6105C2.30694 14.6732 2.79374 14.8036 2.79374 14.8036C4.36003 15.2235 5.32731 16.8549 4.93082 18.3719C4.93082 18.3719 4.80192 18.8649 4.77017 19.0479C4.74082 19.217 4.70097 19.5094 4.82441 19.7995C4.93749 20.0653 5.13453 20.2295 5.28035 20.3327L7.52952 21.6212L7.52962 21.6212C7.6938 21.7139 7.86312 21.8095 8.01315 21.875C8.17639 21.9462 8.41618 22.0285 8.70059 21.9904C9.00977 21.949 9.23894 21.7725 9.37072 21.6623C9.51065 21.5453 9.86249 21.1963 9.86249 21.1963C10.4459 20.6176 11.2229 20.3281 12 20.3279C12.7771 20.3281 13.5541 20.6176 14.1375 21.1963C14.1375 21.1963 14.4894 21.5453 14.6293 21.6623C14.7611 21.7725 14.9902 21.949 15.2994 21.9904C15.5838 22.0285 15.8236 21.9462 15.9869 21.875C16.1369 21.8095 16.3062 21.7139 16.4704 21.6212L16.4705 21.6212L18.7196 20.3327C18.8655 20.2295 19.0625 20.0653 19.1756 19.7995C19.299 19.5094 19.2592 19.217 19.2298 19.0479C19.1981 18.8649 19.0692 18.3719 19.0692 18.3719C18.6727 16.8549 19.64 15.2235 21.2063 14.8036C21.2063 14.8036 21.6931 14.6732 21.8668 14.6105C22.0293 14.5519 22.3009 14.4419 22.4923 14.1936C22.6682 13.9654 22.7148 13.7153 22.7332 13.5383C22.7501 13.375 22.75 13.1797 22.75 12.9897V11.0105C22.75 10.8205 22.7501 10.6252 22.7332 10.4619C22.7148 10.2849 22.6682 10.0347 22.4923 9.80652C22.3009 9.55819 22.0293 9.4482 21.8667 9.3896C21.693 9.32697 21.4709 9.26746 21.2338 9.20395L21.2062 9.19656C19.6393 8.77664 18.6714 7.14492 19.0677 5.62811C19.1307 5.387 19.1965 5.13505 19.2283 4.95206C19.2576 4.78294 19.2974 4.49056 19.174 4.20047C19.0609 3.93475 18.8638 3.77054 18.718 3.66739L16.4689 2.37889C16.3046 2.28616 16.1354 2.19058 15.9853 2.1251C15.8221 2.05387 15.5823 1.9716 15.2979 2.00968C14.9888 2.05108 14.7596 2.22753 14.6278 2.3377C14.4879 2.45467 14.136 2.80369 14.136 2.80369C13.5529 3.38184 12.7765 3.67103 12 3.67124C11.2235 3.67103 10.4471 3.38184 9.86405 2.80369C9.86405 2.80369 9.51213 2.45467 9.37221 2.3377C9.24043 2.22753 9.01124 2.05108 8.70208 2.00968ZM12 15.5C13.933 15.5 15.5 13.933 15.5 12C15.5 10.067 13.933 8.50004 12 8.50004C10.067 8.50004 8.5 10.067 8.5 12C8.5 13.933 10.067 15.5 12 15.5Z" fill="currentColor"></path>
@@ -156,26 +143,6 @@ export const BlockedIcon = (props) => (
</svg>
);
export const SlidersHorizontalIcon = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width={24} height={24} color={"currentColor"} fill={"none"} {...props}>
<path fillRule="evenodd" clipRule="evenodd" d="M10 6L4.00004 6.00024C3.44776 6.00027 3.00002 5.55257 3 5.00029C2.99998 4.448 3.44767 4.00027 3.99996 4.00024L9.99996 4C10.5522 3.99998 11 4.44767 11 4.99996C11 5.55224 10.5523 5.99998 10 6Z" fill="currentColor"></path>
<path fillRule="evenodd" clipRule="evenodd" d="M12 5C12 4.44772 12.4477 4 13 4L20 4C20.5523 4 21 4.44772 21 5C21 5.55229 20.5523 6 20 6L13 6C12.4477 6 12 5.55228 12 5Z" fill="currentColor"></path>
<path fillRule="evenodd" clipRule="evenodd" d="M16 8C16.5523 8 17 8.44772 17 9L17 15C17 15.5523 16.5523 16 16 16C15.4477 16 15 15.5523 15 15L15 9C15 8.44772 15.4477 8 16 8Z" fill="currentColor"></path>
<path fillRule="evenodd" clipRule="evenodd" d="M10 1C10.5523 1 11 1.44772 11 2L11 8C11 8.55228 10.5523 9 10 9C9.44772 9 9 8.55228 9 8L9 2C9 1.44772 9.44772 1 10 1Z" fill="currentColor"></path>
<path fillRule="evenodd" clipRule="evenodd" d="M12 15C12.5523 15 13 15.4477 13 16L13 22C13 22.5523 12.5523 23 12 23C11.4477 23 11 22.5523 11 22L11 16C11 15.4477 11.4477 15 12 15Z" fill="currentColor"></path>
<path fillRule="evenodd" clipRule="evenodd" d="M15 11.9999C15 11.4477 15.4478 11 16.0001 11L20.0001 11.0002C20.5523 11.0003 21 11.448 21 12.0003C21 12.5526 20.5522 13.0003 19.9999 13.0002L15.9999 13C15.4477 13 15 12.5522 15 11.9999Z" fill="currentColor"></path>
<path fillRule="evenodd" clipRule="evenodd" d="M13 13L4.00003 13.0002C3.44774 13.0003 3.00002 12.5526 3 12.0003C2.99998 11.448 3.44769 11.0003 3.99997 11.0002L13 11C13.5523 11 14 11.4477 14 12C14 12.5523 13.5523 13 13 13Z" fill="currentColor"></path>
<path fillRule="evenodd" clipRule="evenodd" d="M11 19C11 18.4477 11.4477 18 12 18L20 18C20.5523 18 21 18.4477 21 19C21 19.5523 20.5523 20 20 20L12 20C11.4477 20 11 19.5523 11 19Z" fill="currentColor"></path>
<path fillRule="evenodd" clipRule="evenodd" d="M9.00005 20L4.00005 20.0002C3.44776 20.0003 3.00003 19.5526 3 19.0003C2.99997 18.448 3.44767 18.0003 3.99995 18.0002L8.99995 18C9.55224 18 9.99997 18.4477 10 19C10 19.5522 9.55233 20 9.00005 20Z" fill="currentColor"></path>
</svg>
);
export const Recycle03Icon = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width={24} height={24} color={"currentColor"} fill={"none"} {...props}>
<path fillRule="evenodd" clipRule="evenodd" d="M5.23138 2.35774C5.43514 1.8585 6.00547 1.61878 6.50527 1.82231L9.43707 3.01619C9.92228 3.21377 10.1651 3.75843 9.98762 4.25087L9.01035 6.9617C8.82748 7.46894 8.26758 7.73207 7.75978 7.54941C7.25197 7.36675 6.98855 6.80747 7.17141 6.30023L7.44465 5.54229C4.9263 6.83943 3.20454 9.46366 3.20454 12.488C3.20454 12.9326 3.24162 13.3678 3.31268 13.7908C3.40198 14.3225 3.04286 14.8258 2.51057 14.915C1.97828 15.0042 1.47438 14.6455 1.38508 14.1138C1.29617 13.5845 1.25 13.0413 1.25 12.488C1.25 8.7674 3.33323 5.53429 6.39763 3.88684L5.76742 3.6302C5.26762 3.42668 5.02763 2.85697 5.23138 2.35774ZM11.5248 3.66239C11.6141 3.13069 12.118 2.77198 12.6503 2.86118C17.273 3.63587 20.7954 7.65011 20.7954 12.488C20.7954 13.0034 20.7553 13.5098 20.6781 14.0041L21.2224 13.6337C21.6684 13.3301 22.2764 13.4452 22.5803 13.8908C22.8842 14.3363 22.7689 14.9436 22.3229 15.2471L19.4541 17.1995C19.2234 17.3565 18.9366 17.4074 18.6658 17.3395C18.3951 17.2716 18.1664 17.0913 18.0373 16.8441L16.5084 13.9156C16.2588 13.4376 16.4445 12.848 16.923 12.5987C17.4016 12.3494 17.9919 12.5348 18.2414 13.0128L18.7112 13.9127C18.7963 13.451 18.8408 12.9749 18.8408 12.488C18.8408 8.61944 16.0238 5.40615 12.3269 4.78663C11.7946 4.69743 11.4355 4.19409 11.5248 3.66239ZM2.7311 17.3689C2.7311 16.8298 3.16864 16.3927 3.70837 16.3927H7.11361C7.65334 16.3927 8.09088 16.8298 8.09088 17.3689C8.09088 17.9081 7.65334 18.3451 7.11361 18.3451H5.85114C7.22998 19.5609 9.04032 20.2975 11.0227 20.2975C13.0257 20.2975 14.8507 19.5463 16.2345 18.3092C16.6367 17.9496 17.2545 17.9838 17.6145 18.3855C17.9744 18.7872 17.9402 19.4044 17.538 19.7639C15.8097 21.3091 13.525 22.2498 11.0227 22.2498C8.60426 22.2498 6.39132 21.3719 4.68563 19.9194V20.7855C4.68563 21.3247 4.2481 21.7617 3.70837 21.7617C3.16864 21.7617 2.7311 21.3247 2.7311 20.7855V17.3689Z" fill="currentColor"></path>
</svg>
);
export const CloudUploadIcon = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width={24} height={24} color={"currentColor"} fill={"none"} {...props}>
<path fillRule="evenodd" clipRule="evenodd" d="M13.0059 21.25C13.0059 21.8023 12.5581 22.25 12.0059 22.25C11.4536 22.25 11.0059 21.8023 11.0059 21.25L11.0059 16.75L10.4116 16.75C10.236 16.7501 10.0203 16.7503 9.84387 16.7282L9.84053 16.7278C9.71408 16.712 9.13804 16.6402 8.86368 16.0746C8.58872 15.5078 8.89065 15.0076 8.95597 14.8994L8.95841 14.8954C9.05062 14.7424 9.18477 14.5715 9.29511 14.4309L9.31885 14.4007C9.61348 14.0249 9.99545 13.5406 10.3759 13.1496C10.5657 12.9545 10.783 12.7533 11.0139 12.5944C11.2191 12.4532 11.5693 12.25 12 12.25C12.4307 12.25 12.7809 12.4532 12.9861 12.5944C13.217 12.7533 13.4343 12.9545 13.6241 13.1496C14.0046 13.5406 14.3865 14.0249 14.6812 14.4007L14.7049 14.4309C14.8152 14.5715 14.9494 14.7424 15.0416 14.8954L15.044 14.8994C15.1093 15.0076 15.4113 15.5078 15.1363 16.0746C14.862 16.6402 14.2859 16.712 14.1595 16.7278L14.1561 16.7282C13.9797 16.7503 13.764 16.7501 13.5884 16.75L13.0059 16.75L13.0059 21.25Z" fill="currentColor"></path>
@@ -227,14 +194,6 @@ export const ScrollIcon = (props) => (
</svg>
);
export const Menu01Icon = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width={24} height={24} color={"currentColor"} fill={"none"} {...props}>
<path fillRule="evenodd" clipRule="evenodd" d="M3 5C3 4.44772 3.44772 4 4 4L20 4C20.5523 4 21 4.44772 21 5C21 5.55229 20.5523 6 20 6L4 6C3.44772 6 3 5.55228 3 5Z" fill="currentColor" />
<path fillRule="evenodd" clipRule="evenodd" d="M3 12C3 11.4477 3.44772 11 4 11L20 11C20.5523 11 21 11.4477 21 12C21 12.5523 20.5523 13 20 13L4 13C3.44772 13 3 12.5523 3 12Z" fill="currentColor" />
<path fillRule="evenodd" clipRule="evenodd" d="M3 19C3 18.4477 3.44772 18 4 18L20 18C20.5523 18 21 18.4477 21 19C21 19.5523 20.5523 20 20 20L4 20C3.44772 20 3 19.5523 3 19Z" fill="currentColor" />
</svg>
);
export const MailEdit01Icon = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width={24} height={24} color={"currentColor"} fill={"none"} {...props}>
<path d="M18.3285 13.8039C18.6619 13.4705 18.8285 13.3039 19.0019 13.2037C19.4725 12.9321 20.0522 12.9321 20.5227 13.2037C20.6961 13.3039 20.8628 13.4705 21.1961 13.8039C21.5295 14.1372 21.6961 14.3039 21.7963 14.4773C22.0679 14.9478 22.0679 15.5275 21.7963 15.9981C21.6961 16.1715 21.5295 16.3381 21.1961 16.6715L17.8883 19.9793C17.2274 20.6402 16.2301 20.6671 15.3477 20.8557C14.6578 21.0032 14.3128 21.077 14.1179 20.8821C13.923 20.6872 13.9968 20.3422 14.1443 19.6523C14.3329 18.7699 14.3598 17.7726 15.0207 17.1117L18.3285 13.8039Z" fill="currentColor"></path>
@@ -336,7 +295,6 @@ export const CancelCircleIcon = (props) => (
</svg>
);
export const Link02Icon = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width={24} height={24} color={"currentColor"} fill={"none"} {...props}>
<path fillRule="evenodd" clipRule="evenodd" d="M15.2071 8.79285C15.5976 9.18337 15.5976 9.81654 15.2071 10.2071L10.2071 15.2071C9.81658 15.5976 9.18342 15.5976 8.79289 15.2071C8.40237 14.8165 8.40237 14.1834 8.79289 13.7928L13.7929 8.79285C14.1834 8.40232 14.8166 8.40232 15.2071 8.79285Z" fill="currentColor"></path>
@@ -353,30 +311,9 @@ export const UserGroupIcon = (props) => (
);
export const Copy01Icon = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width={24} height={24} color={"currentColor"} fill={"none"} {...props}>
<path d="M9.15051 5.25H8.9426C7.55182 5.24998 6.44301 5.24997 5.56489 5.37061C4.65689 5.49531 3.89785 5.76091 3.29285 6.39285C2.68785 7.02479 2.43531 7.81689 2.31561 8.76489C2.19997 9.68301 2.19998 10.8418 2.25 12.2926V16.2074C2.19998 17.6582 2.19997 18.817 2.31561 19.7351C2.43531 20.6831 2.68785 21.4752 3.29285 22.1072C3.89785 22.7391 4.65689 22.9847 5.56489 23.0994C6.44301 23.2151 7.55182 23.215 8.9426 23.215H12.7074C14.0982 23.215 15.207 23.2151 16.0851 23.0994C16.9931 22.9847 17.7522 22.7391 18.3572 22.1072C18.9622 21.4752 19.2147 20.6831 19.3344 19.7351C19.4501 18.817 19.45 17.6582 19.4 16.2074V16.0995C19.45 15.3995 19.45 14.5995 19.45 13.6995" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"></path>
<path d="M19.25 11.25H14.75C12.9551 11.25 11.5 9.79493 11.5 8V3.5M19.25 11.25C19.25 10.3839 18.8978 9.56607 18.3033 8.94625L13.8037 4.19675C13.1713 3.53775 12.3017 3.16 11.5 3.16V3.5M19.25 11.25V14.5074C19.25 15.9582 19.25 17.117 19.1344 18.0351C19.0147 18.9831 18.7622 19.7752 18.1572 20.4072C17.5522 21.0391 16.7931 21.2847 15.8851 21.3994C15.007 21.5151 13.8982 21.515 12.5074 21.515H8.7426C7.35182 21.515 6.24301 21.5151 5.36489 21.3994C4.45689 21.2847 3.69785 21.0391 3.09285 20.4072C2.48785 19.7752 2.23531 18.9831 2.11561 18.0351C1.99997 17.117 1.99998 15.9582 2 14.5074V10.7926C1.99998 9.34182 1.99997 8.18301 2.11561 7.26489C2.23531 6.31689 2.48785 5.52479 3.09285 4.89285C3.69785 4.26091 4.45689 4.01531 5.36489 3.90061C6.24301 3.78497 7.35182 3.78498 8.7426 3.785H11.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"></path>
<path d="M6.5 0.75H13.0574C14.4482 0.74998 15.557 0.74997 16.4351 0.87061C17.3431 0.99531 18.1022 1.24091 18.7072 1.87285C19.3122 2.50479 19.5647 3.29689 19.6844 4.24489C19.8001 5.16301 19.8 6.32182 19.75 7.77261" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"></path>
</svg>
);
export const Pdf01Icon = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width={24} height={24} color={"currentColor"} fill={"none"} {...props}>
<path fillRule="evenodd" clipRule="evenodd" d="M11.5874 1.2501C12.1574 1.24934 12.6619 1.24866 13.1373 1.41733C13.2353 1.45209 13.3314 1.49208 13.4251 1.53715C13.8799 1.75582 14.2362 2.11489 14.6387 2.52054L19.4215 7.32819C19.8889 7.79654 20.3036 8.2122 20.5277 8.75596C20.7518 9.29971 20.7509 9.88846 20.7499 10.5518L20.7498 17.0262V17.0262C20.75 18.4009 20.75 19.5066 20.6346 20.3822C20.5149 21.2911 20.2621 22.0595 19.6558 22.6658C19.0495 23.2721 18.2811 23.5249 17.3722 23.6446C16.4966 23.76 15.3909 23.76 14.0162 23.75H9.98378H9.98376C8.60909 23.76 7.50342 23.76 6.62779 23.6446C5.71893 23.5249 4.95053 23.2721 4.34422 22.6658C3.73791 22.0595 3.48515 21.2911 3.36542 20.3822C3.25003 19.5066 3.25005 18.4009 3.25006 17.0262L3.25006 6.97378C3.25005 5.5991 3.25003 4.49343 3.36542 3.6178C3.48515 2.70894 3.73791 1.94054 4.34422 1.33423C4.95053 0.727916 5.71893 0.475156 6.62779 0.355432C7.50341 0.240045 8.60906 0.240063 9.98373 0.240107L11.5874 1.2501ZM18.7314 9.50391C18.6746 9.36608 18.5692 9.2357 17.9511 8.6144L13.3462 3.98554C12.8133 3.44983 12.4902 3.26172 12.4902 3.26172V3.2701C12.4902 4.63195 12.4902 5.27967 12.6065 6.14486C12.7275 7.04497 12.9864 7.80284 13.5884 8.40476C14.1903 9.00667 14.9481 9.26557 15.8483 9.38658C16.7152 9.50314 17.3638 9.50393 18.7314 9.50391ZM7.25 13C7.25 12.5858 7.58579 12.25 8 12.25H10C10.4142 12.25 10.75 12.5858 10.75 13C10.75 13.4142 10.4142 13.75 10 13.75H8C7.58579 13.75 7.25 13.4142 7.25 13ZM8 16.25C7.58579 16.25 7.25 16.5858 7.25 17C7.25 17.4142 7.58579 17.75 8 17.75H14C14.4142 17.75 14.75 17.4142 14.75 17C14.75 16.5858 14.4142 16.25 14 16.25H8Z" fill="currentColor"></path>
</svg>
);
export const ArrowUp01Icon = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width={24} height={24} color={"currentColor"} fill={"none"} {...props}>
<path fillRule="evenodd" clipRule="evenodd" d="M12 4.25C12.4142 4.25 12.75 4.58579 12.75 5V19C12.75 19.4142 12.4142 19.75 12 19.75C11.5858 19.75 11.25 19.4142 11.25 19V5C11.25 4.58579 11.5858 4.25 12 4.25Z" fill="currentColor"></path>
<path fillRule="evenodd" clipRule="evenodd" d="M11.4697 4.46967C11.7626 4.17678 12.2374 4.17678 12.5303 4.46967L18.5303 10.4697C18.8232 10.7626 18.8232 11.2374 18.5303 11.5303C18.2374 11.8232 17.7626 11.8232 17.4697 11.5303L12 6.06066L6.53033 11.5303C6.23744 11.8232 5.76256 11.8232 5.46967 11.5303C5.17678 11.2374 5.17678 10.7626 5.46967 10.4697L11.4697 4.46967Z" fill="currentColor"></path>
</svg>
);
export const ArrowDown01Icon = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width={24} height={24} color={"currentColor"} fill={"none"} {...props}>
<path fillRule="evenodd" clipRule="evenodd" d="M12 4.25C12.4142 4.25 12.75 4.58579 12.75 5V19C12.75 19.4142 12.4142 19.75 12 19.75C11.5858 19.75 11.25 19.4142 11.25 19V5C11.25 4.58579 11.5858 4.25 12 4.25Z" fill="currentColor"></path>
<path fillRule="evenodd" clipRule="evenodd" d="M5.46967 12.4697C5.76256 12.1768 6.23744 12.1768 6.53033 12.4697L12 17.9393L17.4697 12.4697C17.7626 12.1768 18.2374 12.1768 18.5303 12.4697C18.8232 12.7626 18.8232 13.2374 18.5303 13.5303L12.5303 19.5303C12.2374 19.8232 11.7626 19.8232 11.4697 19.5303L5.46967 13.5303C5.17678 13.2374 5.17678 12.7626 5.46967 12.4697Z" fill="currentColor"></path>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width={64} height={64} color={"currentColor"} fill={"none"} {...props}>
<path d="M16.0549 8.25C17.4225 8.24998 18.5248 8.24996 19.3918 8.36652C20.2919 8.48754 21.0497 8.74643 21.6517 9.34835C22.2536 9.95027 22.5125 10.7081 22.6335 11.6083C22.75 12.4752 22.75 13.5775 22.75 14.9451V14.9451V16.0549V16.0549C22.75 17.4225 22.75 18.5248 22.6335 19.3918C22.5125 20.2919 22.2536 21.0497 21.6517 21.6517C21.0497 22.2536 20.2919 22.5125 19.3918 22.6335C18.5248 22.75 17.4225 22.75 16.0549 22.75H16.0549H14.9451H14.9451C13.5775 22.75 12.4752 22.75 11.6082 22.6335C10.7081 22.5125 9.95027 22.2536 9.34835 21.6516C8.74643 21.0497 8.48754 20.2919 8.36652 19.3918C8.24996 18.5248 8.24998 17.4225 8.25 16.0549V16.0549V14.9451V14.9451C8.24998 13.5775 8.24996 12.4752 8.36652 11.6082C8.48754 10.7081 8.74643 9.95027 9.34835 9.34835C9.95027 8.74643 10.7081 8.48754 11.6083 8.36652C12.4752 8.24996 13.5775 8.24998 14.9451 8.25H14.9451H16.0549H16.0549Z" fill="currentColor" />
<path d="M6.75 14.8569C6.74991 13.5627 6.74983 12.3758 6.8799 11.4084C7.0232 10.3425 7.36034 9.21504 8.28769 8.28769C9.21504 7.36034 10.3425 7.0232 11.4084 6.8799C12.3758 6.74983 13.5627 6.74991 14.8569 6.75L17.0931 6.75C17.3891 6.75 17.5371 6.75 17.6261 6.65419C17.7151 6.55838 17.7045 6.4142 17.6833 6.12584C17.6648 5.87546 17.6412 5.63892 17.6111 5.41544C17.4818 4.45589 17.2232 3.6585 16.6718 2.98663C16.4744 2.74612 16.2539 2.52558 16.0134 2.3282C15.3044 1.74638 14.4557 1.49055 13.4248 1.36868C12.4205 1.24998 11.1512 1.24999 9.54893 1.25H9.45109C7.84883 1.24999 6.57947 1.24998 5.57525 1.36868C4.54428 1.49054 3.69558 1.74638 2.98663 2.3282C2.74612 2.52558 2.52558 2.74612 2.3282 2.98663C1.74638 3.69558 1.49055 4.54428 1.36868 5.57525C1.24998 6.57947 1.24999 7.84882 1.25 9.45108V9.54891C1.24999 11.1512 1.24998 12.4205 1.36868 13.4247C1.49054 14.4557 1.74638 15.3044 2.3282 16.0134C2.52558 16.2539 2.74612 16.4744 2.98663 16.6718C3.6585 17.2232 4.45589 17.4818 5.41544 17.6111C5.63892 17.6412 5.87546 17.6648 6.12584 17.6833C6.4142 17.7045 6.55838 17.7151 6.65419 17.6261C6.75 17.5371 6.75 17.3891 6.75 17.0931V14.8569Z" fill="currentColor" />
</svg>
);
@@ -572,3 +509,32 @@ export const User03Icon = (props) => (
<path d="M4.25 19C4.25 15.8244 6.82436 13.25 10 13.25H14C17.1756 13.25 19.75 15.8244 19.75 19C19.75 20.5188 18.5188 21.75 17 21.75H7C5.48122 21.75 4.25 20.5188 4.25 19Z" fill="currentColor"></path>
</svg>
);
export const Logout02Icon = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width={64} height={64} color={"currentColor"} fill={"none"} {...props}>
<path d="M11.6856 3.25027C11.7518 3.76376 11.75 4.41982 11.75 5.19656L11.75 18.863C11.7501 19.614 11.7501 20.2498 11.6856 20.7503C11.6176 21.2766 11.4638 21.7901 11.0469 22.1848C10.925 22.3003 10.79 22.4021 10.6455 22.4876C10.1515 22.7798 9.61553 22.7866 9.09084 22.7073C8.59102 22.6317 7.9783 22.4567 7.25445 22.2499L7.15469 22.2214C6.42537 22.0131 5.82294 21.841 5.34572 21.6497C4.84346 21.4484 4.41446 21.2011 4.06642 20.8079C3.95414 20.681 3.85193 20.5454 3.76076 20.4026C3.47827 19.9601 3.3584 19.4798 3.30275 18.9417C3.24997 18.4311 3.24999 17.8059 3.25002 17.0492V6.95133C3.24999 6.1946 3.24997 5.5694 3.30275 5.05887C3.3584 4.52076 3.47827 4.04049 3.76076 3.59793C3.85192 3.45513 3.95414 3.31952 4.06642 3.19265C4.41446 2.79943 4.84346 2.55219 5.34572 2.35086C5.82295 2.15956 6.42538 1.98749 7.1547 1.77918L7.20119 1.7659C7.94828 1.55248 8.57881 1.37065 9.09084 1.29324C9.61553 1.21396 10.1515 1.22077 10.6455 1.51297C10.79 1.59847 10.925 1.70028 11.0469 1.8157C11.4638 2.21046 11.6176 2.72394 11.6856 3.25027Z" fill="currentColor" />
<path d="M16.8483 16.7549C16.2988 16.7005 15.8091 17.1019 15.7546 17.6514C15.699 18.2122 15.5908 18.3696 15.5056 18.4541C15.4273 18.5317 15.2875 18.627 14.8483 18.6856C14.3805 18.748 13.7452 18.75 12.7663 18.75H10.7497C10.1974 18.75 9.74971 19.1978 9.74971 19.75C9.74971 20.3023 10.1974 20.75 10.7497 20.75H12.7663C13.6891 20.75 14.4813 20.7521 15.112 20.668C15.7706 20.5802 16.4013 20.3822 16.9138 19.8741C17.4873 19.3054 17.6715 18.5879 17.7448 17.8487C17.7993 17.2991 17.3979 16.8094 16.8483 16.7549ZM10.7497 3.25004C10.1974 3.25004 9.74971 3.69776 9.74971 4.25004C9.74971 4.80233 10.1974 5.25004 10.7497 5.25004H12.7663C13.7452 5.25004 14.3805 5.25213 14.8483 5.3145C15.2875 5.37309 15.4273 5.46837 15.5056 5.54594C15.5908 5.63048 15.699 5.78789 15.7546 6.34868C15.8091 6.89823 16.2988 7.29963 16.8483 7.24516C17.3979 7.19064 17.7993 6.70096 17.7448 6.15141C17.6715 5.41215 17.4873 4.69466 16.9138 4.12602C16.4013 3.61792 15.7706 3.41987 15.112 3.33207C14.4813 3.24801 13.6891 3.25004 12.7663 3.25004H10.7497Z" fill="currentColor" />
<path d="M19.8428 8.69444C19.3982 8.36731 18.7728 8.46282 18.4453 8.90733C18.1178 9.35202 18.2126 9.97728 18.6573 10.3048C18.722 10.3537 18.913 10.4983 19.0254 10.586C19.1727 10.701 19.3494 10.8445 19.5371 11.0001H14.25C13.6978 11.0001 13.2501 11.4478 13.25 12.0001C13.25 12.5524 13.6977 13.0001 14.25 13.0001H19.5371C19.3494 13.1557 19.1727 13.2992 19.0254 13.4142C18.913 13.5019 18.6429 13.711 18.5782 13.7599C18.2024 14.1001 18.1383 14.6759 18.4453 15.0929C18.7728 15.5374 19.3982 15.6329 19.8428 15.3058C19.9147 15.2514 20.134 15.0855 20.2559 14.9903C20.4986 14.8009 20.8254 14.5385 21.1553 14.2521C21.4802 13.97 21.8311 13.645 22.1084 13.3312C22.2461 13.1753 22.3873 12.998 22.4991 12.8126C22.5943 12.6546 22.75 12.3607 22.75 12.0001C22.75 11.6395 22.5943 11.3456 22.4991 11.1876C22.3873 11.0022 22.2461 10.8249 22.1084 10.669C21.8311 10.3552 21.4802 10.0302 21.1553 9.74815C20.8254 9.46171 20.4986 9.19929 20.2559 9.00987C20.134 8.91474 19.9147 8.74878 19.8428 8.69444Z" fill="currentColor" />
</svg>
);
export const SmartPhone01Icon = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width={64} height={64} color={"currentColor"} fill={"none"} {...props}>
<path d="M14.3643 1.25195C15.1368 1.25654 15.7946 1.27495 16.3428 1.34863C17.1067 1.45134 17.7692 1.67346 18.2979 2.20215C18.8265 2.73084 19.0487 3.39328 19.1514 4.15723C19.2496 4.88804 19.25 5.81361 19.25 6.94629V17.0537C19.25 18.1864 19.2496 19.112 19.1514 19.8428C19.0487 20.6067 18.8265 21.2692 18.2979 21.7979C17.7692 22.3265 17.1067 22.5487 16.3428 22.6514C15.612 22.7496 14.6864 22.75 13.5537 22.75H10.4463L9.63574 22.748C8.86316 22.7435 8.20542 22.725 7.65723 22.6514C6.89328 22.5487 6.23084 22.3265 5.70215 21.7979C5.17346 21.2692 4.95134 20.6067 4.84863 19.8428C4.75041 19.112 4.74998 18.1864 4.75 17.0537V6.94629L4.75195 6.13574C4.75654 5.36316 4.77495 4.70542 4.84863 4.15723C4.95134 3.39328 5.17346 2.73084 5.70215 2.20215C6.23084 1.67346 6.89328 1.45134 7.65723 1.34863C8.38804 1.25041 9.31361 1.24998 10.4463 1.25H13.5537L14.3643 1.25195ZM12 18C11.4477 18 11 18.4477 11 19C11 19.5523 11.4477 20 12 20C12.5523 20 13 19.5523 13 19C13 18.4477 12.5523 18 12 18Z" fill="currentColor"></path>
</svg>
);
export const ComputerIcon = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width={64} height={64} color={"currentColor"} fill={"none"} {...props}>
<path d="M16.0549 2.25C17.4225 2.24998 18.5248 2.24996 19.3918 2.36652C20.2919 2.48754 21.0497 2.74643 21.6517 3.34835C22.2536 3.95027 22.5125 4.70814 22.6335 5.60825C22.75 6.47522 22.75 7.57754 22.75 8.94513V11.0549C22.75 12.4225 22.75 13.5248 22.6335 14.3918C22.5125 15.2919 22.2536 16.0497 21.6517 16.6517C21.0497 17.2536 20.2919 17.5125 19.3918 17.6335C18.5248 17.75 17.4225 17.75 16.0549 17.75H16.0549H7.94513H7.94512C6.57754 17.75 5.47522 17.75 4.60825 17.6335C3.70814 17.5125 2.95027 17.2536 2.34835 16.6517C1.74643 16.0497 1.48754 15.2919 1.36652 14.3918C1.24996 13.5248 1.24998 12.4225 1.25 11.0549V11.0549V8.94513V8.94511C1.24998 7.57753 1.24996 6.47521 1.36652 5.60825C1.48754 4.70814 1.74643 3.95027 2.34835 3.34835C2.95027 2.74643 3.70814 2.48754 4.60825 2.36652C5.47521 2.24996 6.57753 2.24998 7.94511 2.25H7.94513H16.0549H16.0549Z" fill="currentColor" />
<path fillRule="evenodd" clipRule="evenodd" d="M10.5 16.75C10.5 16.1977 10.9477 15.75 11.5 15.75H12.5C13.0523 15.75 13.5 16.1977 13.5 16.75V19.25C13.5 19.5261 13.7239 19.75 14 19.75H16C16.5523 19.75 17 20.1977 17 20.75C17 21.3023 16.5523 21.75 16 21.75H8C7.44772 21.75 7 21.3023 7 20.75C7 20.1977 7.44772 19.75 8 19.75H10C10.2761 19.75 10.5 19.5261 10.5 19.25V16.75Z" fill="currentColor" />
</svg>
);
export const Menu01Icon = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width={64} height={64} color={"currentColor"} fill={"none"} {...props}>
<path fillRule="evenodd" clipRule="evenodd" d="M3 5C3 4.44772 3.44772 4 4 4L20 4C20.5523 4 21 4.44772 21 5C21 5.55229 20.5523 6 20 6L4 6C3.44772 6 3 5.55228 3 5Z" fill="currentColor"></path>
<path fillRule="evenodd" clipRule="evenodd" d="M3 12C3 11.4477 3.44772 11 4 11L20 11C20.5523 11 21 11.4477 21 12C21 12.5523 20.5523 13 20 13L4 13C3.44772 13 3 12.5523 3 12Z" fill="currentColor"></path>
<path fillRule="evenodd" clipRule="evenodd" d="M3 19C3 18.4477 3.44772 18 4 18L20 18C20.5523 18 21 18.4477 21 19C21 19.5523 20.5523 20 20 20L4 20C3.44772 20 3 19.5523 3 19Z" fill="currentColor"></path>
</svg>
);
+4
View File
@@ -48,3 +48,7 @@ export function getAppConfig() {
currencySymbol: process.env.ZEN_CURRENCY_SYMBOL || '$',
};
}
export function isDevkitEnabled() {
return process.env.ZEN_DEVKIT === 'true';
}
+23 -1
View File
@@ -19,7 +19,11 @@ import { registerStoragePolicies, clearStorageConfig } from '@zen/core/storage';
import { validateSession } from '../../features/auth/session.js';
import { routes as authRoutes } from '../../features/auth/api.js';
import { storageAccessPolicies } from '../../features/auth/storage-policies.js';
import { done, warn } from './logger.js';
import { PERMISSION_DEFINITIONS } from '../../core/users/constants.js';
import { registerPermissions, clearRegisteredPermissions } from '../../core/users/permissions-registry.js';
import { discoverModules, validateModuleEnvVars } from '../../core/modules/discover.server.js';
import { getRegisteredModules, clearRegisteredModules } from '../../core/modules/registry.js';
import { done, warn, fail } from './logger.js';
const ZEN_INIT_KEY = Symbol.for('__ZEN_INITIALIZED__');
@@ -38,6 +42,22 @@ export async function initializeZen() {
configureRouter({ resolveSession: validateSession });
registerFeatureRoutes(authRoutes);
registerStoragePolicies(storageAccessPolicies);
registerPermissions(PERMISSION_DEFINITIONS);
// Découverte et activation des modules @zen/module-*
await discoverModules();
const modules = getRegisteredModules();
validateModuleEnvVars(modules);
for (const mod of modules) {
if (Array.isArray(mod.manifest?.permissions)) {
registerPermissions(mod.manifest.permissions);
}
try {
await mod.register();
} catch (error) {
fail(`zen-modules: ${mod.manifest.name} register() threw — ${error.message}`);
}
}
done('ZEN: ready');
@@ -49,5 +69,7 @@ export function resetZenInitialization() {
clearRouterConfig();
clearFeatureRoutes();
clearStorageConfig();
clearRegisteredPermissions();
clearRegisteredModules();
warn('ZEN: initialization reset');
}
+6 -6
View File
@@ -122,12 +122,12 @@ export function getIpFromHeaders(headersList) {
const realIp = headersList.get('x-real-ip')?.trim();
if (realIp && isValidIp(realIp)) return realIp;
}
// Fallback when no trusted proxy is configured.
// Callers (router.js, authActions.js) treat 'unknown' as a signal to suspend
// rate limiting rather than collapse all traffic into one shared bucket — which
// would allow a single attacker to exhaust the quota and deny service globally.
// In development, use loopback so rate limiting stays active and the
// "IP cannot be determined" warning is not emitted.
// In production without a trusted proxy, return 'unknown' to suspend rate
// limiting rather than collapse all traffic into one shared bucket.
// Configure ZEN_TRUST_PROXY=true behind a verified reverse proxy.
return 'unknown';
return process.env.NODE_ENV === 'development' ? '127.0.0.1' : 'unknown';
}
/**
@@ -143,7 +143,7 @@ export function getIpFromRequest(request) {
const realIp = request.headers.get('x-real-ip')?.trim();
if (realIp && isValidIp(realIp)) return realIp;
}
return 'unknown';
return process.env.NODE_ENV === 'development' ? '127.0.0.1' : 'unknown';
}
/**