Compare commits

...

41 Commits

Author SHA1 Message Date
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
59 changed files with 2633 additions and 172 deletions
+3
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
+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.
+1 -1
View File
@@ -88,7 +88,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' });
```
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@zen/core",
"version": "1.4.99",
"version": "1.4.117",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@zen/core",
"version": "1.4.99",
"version": "1.4.117",
"license": "GPL-3.0-only",
"dependencies": {
"@headlessui/react": "^2.0.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@zen/core",
"version": "1.4.99",
"version": "1.4.117",
"description": "Une plateforme multi-usage construite sur l'essentiel, rien de plus, rien de moins.",
"repository": {
"type": "git",
+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.edit'`). 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.edit'). 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.
+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();
}
+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`.
+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}"`,
},
});
}
```
+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 };
}
```
+272
View File
@@ -0,0 +1,272 @@
# 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_EDIT],
});
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`) ne peuvent pas être renommés ni supprimés.
---
### 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.edit`, `users.delete` |
| 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 };
+12 -32
View File
@@ -4,41 +4,21 @@
*/
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_EDIT: 'users.edit',
USERS_DELETE: 'users.delete',
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.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' },
];
/**
+1 -1
View File
@@ -3,7 +3,7 @@ import { generateId } from './password.js';
import { done, warn } from '@zen/core/shared/logger';
import { PERMISSION_DEFINITIONS } from './constants.js';
const USER_ROLE_PERMISSIONS = ['content.view', 'media.view'];
const USER_ROLE_PERMISSIONS = [];
const ROLE_TABLES = [
{
+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 (
+7 -2
View File
@@ -3,18 +3,23 @@ import { protectAdmin } from './protect.js';
import { collectWidgetData } from './registry.js';
import { getAppConfig, getPublicBaseUrl } from '@zen/core';
import { isDevkitEnabled } from '../../shared/lib/appConfig.js';
import { getUserPermissions } from '@zen/core/users';
export default async function AdminPage({ params }) {
const resolvedParams = await params;
const session = await protectAdmin();
const widgetData = await collectWidgetData();
const [widgetData, permissions] = await Promise.all([
collectWidgetData(),
getUserPermissions(session.user.id),
]);
const appConfig = { ...getAppConfig(), siteUrl: getPublicBaseUrl() };
const devkitEnabled = isDevkitEnabled();
const user = { ...session.user, permissions };
return (
<AdminPageClient
params={resolvedParams}
user={session.user}
user={user}
widgetData={widgetData}
appConfig={appConfig}
devkitEnabled={devkitEnabled}
+298
View File
@@ -0,0 +1,298 @@
# 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.
---
## 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'
}`}
+16 -9
View File
@@ -2,7 +2,7 @@
import { Fragment, useState, useEffect } from 'react';
import { Menu, MenuButton, MenuItem, MenuItems, Transition } from '@headlessui/react';
import { ChevronDownIcon, ChevronRightIcon, Menu01Icon, User03Icon, DashboardSquare03Icon, Logout02Icon } from '@zen/core/shared/icons';
import { ArrowDown01Icon, ArrowRight01Icon, Menu01Icon, User03Icon, DashboardSquare03Icon, Logout02Icon } from '@zen/core/shared/icons';
import { UserAvatar } from '@zen/core/shared/components';
import { useRouter, usePathname } from 'next/navigation';
import { getPage, getPages } from '../registry.js';
@@ -47,7 +47,6 @@ const AdminTop = ({ isMobileMenuOpen, setIsMobileMenuOpen, user, onLogout, appNa
const crumbs = [{ icon: DashboardSquare03Icon, href: '/admin/dashboard' }];
const after = pathname.replace(/^\/admin\/?/, '');
const segments = after.split('/').filter(Boolean);
const [first, second] = segments;
if (!after || !segments.length || (segments[0] === 'dashboard' && segments.length === 1)) {
crumbs.push({ label: pageTitle });
@@ -55,8 +54,15 @@ const AdminTop = ({ isMobileMenuOpen, setIsMobileMenuOpen, user, onLogout, appNa
}
const allItems = navigationSections.flatMap(s => s.items);
const navItem = allItems.find(item => item.href.replace('/admin/', '').split('/')[0] === first);
const hasSubPage = segments.length > 1;
const navItem = allItems.find(item => {
const itemSegs = item.href.replace('/admin/', '').split('/').filter(Boolean);
return itemSegs.length <= segments.length && itemSegs.every((seg, i) => segments[i] === seg);
});
const itemSegCount = navItem
? navItem.href.replace('/admin/', '').split('/').filter(Boolean).length
: 1;
const hasSubPage = segments.length > itemSegCount;
if (navItem) {
crumbs.push({ label: navItem.name, href: hasSubPage ? navItem.href : undefined });
@@ -65,10 +71,11 @@ const AdminTop = ({ isMobileMenuOpen, setIsMobileMenuOpen, user, onLogout, appNa
return crumbs;
}
if (second === 'new') {
const subSegment = segments[itemSegCount];
if (subSegment === 'new') {
crumbs.push({ label: 'Nouveau' });
} else if (second === 'edit') {
const page = getPages().find(p => p.slug === `${first}:edit`);
} else if (subSegment === 'edit') {
const page = getPages().find(p => p.slug === `${segments[0]}:edit`);
crumbs.push({ label: page?.breadcrumbLabel || page?.title || 'Modifier' });
}
@@ -97,7 +104,7 @@ const AdminTop = ({ isMobileMenuOpen, setIsMobileMenuOpen, user, onLogout, appNa
{breadcrumbs.length > 0 && breadcrumbs.map((crumb, i) => (
<Fragment key={i}>
{i > 0 && (
<ChevronRightIcon className="w-3 h-3 text-neutral-400 dark:text-neutral-600 flex-shrink-0" />
<ArrowRight01Icon className="w-3 h-3 text-neutral-400 dark:text-neutral-600 flex-shrink-0" />
)}
{crumb.icon ? (
<button
@@ -130,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
@@ -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;
+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';
@@ -60,8 +60,8 @@ export default function IconsPage() {
title={name}
className="aspect-square flex flex-col items-center justify-center gap-2 rounded-lg border border-neutral-200 dark:border-neutral-800 bg-neutral-100 dark:bg-neutral-900 hover:border-blue-500 dark:hover:border-blue-500 transition-colors duration-100 group cursor-pointer p-2"
>
<IconComponent className="w-7 h-7 text-white transition-colors" />
<span className="text-[9px] text-neutral-400 dark:text-neutral-500 leading-tight text-center break-all line-clamp-2 group-hover:text-neutral-600 dark:group-hover:text-neutral-300 w-full px-1">
<IconComponent className="w-7 h-7 text-black dark:text-white" />
<span className="text-[9px] text-neutral-500 dark:text-neutral-400 leading-tight text-center break-all line-clamp-2 group-hover:text-neutral-600 dark:group-hover:text-neutral-300 w-full px-1">
{name.replace('Icon', '')}
</span>
</button>
+12 -4
View File
@@ -5,14 +5,15 @@ import {
getNavItems,
} from './registry.js';
import { isDevkitEnabled } from '../../shared/lib/appConfig.js';
import { PERMISSIONS } from '@zen/core/users';
// Sections et items core — enregistrés à l'import de ce module.
registerNavSection({ id: 'dashboard', title: 'Tableau de bord', icon: 'DashboardSquare03Icon', order: 10 });
registerNavSection({ id: 'system', title: 'Utilisateurs', icon: 'UserMultiple02Icon', order: 20 });
registerNavItem({ id: 'dashboard', label: 'Tableau de bord', icon: 'DashboardSquare03Icon', href: '/admin/dashboard', sectionId: 'dashboard', order: 10 });
registerNavItem({ id: 'users', label: 'Utilisateurs', icon: 'UserMultiple02Icon', href: '/admin/users', sectionId: 'system', order: 10 });
registerNavItem({ id: 'roles', label: 'Rôles', icon: 'Crown03Icon', href: '/admin/roles', sectionId: 'system', order: 20 });
registerNavItem({ id: 'users', label: 'Utilisateurs', icon: 'UserMultiple02Icon', href: '/admin/users', sectionId: 'system', order: 10, permission: PERMISSIONS.USERS_VIEW });
registerNavItem({ id: 'roles', label: 'Rôles', icon: 'Crown03Icon', href: '/admin/roles', sectionId: 'system', order: 20, permission: PERMISSIONS.ROLES_VIEW });
registerNavItem({ id: 'settings', label: 'Paramètres', icon: 'Settings02Icon', href: '/admin/settings', position: 'bottom', order: 10 });
if (isDevkitEnabled()) {
@@ -24,10 +25,17 @@ if (isDevkitEnabled()) {
/**
* Build sections for AdminSidebar. Items are sérialisables (pas de composants),
* icônes en chaînes résolues côté client.
* @param {string} pathname
* @param {string[]} [userPermissions] - Permissions de l'utilisateur connecté ; les items
* avec un champ `permission` sont masqués si la permission n'est pas présente.
*/
export function buildNavigationSections(pathname) {
export function buildNavigationSections(pathname, userPermissions = []) {
const sections = getNavSections();
const items = getNavItems().filter(item => item.position !== 'bottom');
const items = getNavItems().filter(item => {
if (item.position === 'bottom') return false;
if (item.permission && !userPermissions.includes(item.permission)) return false;
return true;
});
const bySection = new Map();
for (const item of items) {
@@ -3,9 +3,10 @@
import { getWidgets, registerPage } from '../registry.js';
import AdminHeader from '../components/AdminHeader.js';
export default function DashboardPage({ stats }) {
export default function DashboardPage({ user, stats }) {
const loading = stats === null || stats === undefined;
const widgets = getWidgets();
const permissions = user?.permissions ?? [];
const widgets = getWidgets().filter(w => !w.permission || permissions.includes(w.permission));
return (
<div className="flex flex-col gap-4 sm:gap-6 lg:gap-8">
+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)}
/>
)}
</>
);
};
+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.edit');
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();
+100 -18
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 } 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';
@@ -807,6 +808,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 +896,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_EDIT },
{ 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 +907,17 @@ 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_EDIT },
{ path: '/users/:id/roles/:roleId', method: 'DELETE', handler: handleRevokeUserRole, auth: 'admin', permission: PERMISSIONS.USERS_EDIT },
{ 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_EDIT },
{ path: '/users/:id/email', method: 'PUT', handler: handleAdminUpdateUserEmail, auth: 'admin', permission: PERMISSIONS.USERS_EDIT },
{ path: '/users/:id/password', method: 'PUT', handler: handleAdminSetUserPassword, auth: 'admin', permission: PERMISSIONS.USERS_EDIT },
{ path: '/users/:id/send-password-reset', method: 'POST', handler: handleAdminSendPasswordReset, auth: 'admin', permission: PERMISSIONS.USERS_EDIT },
{ path: '/roles', method: 'GET', handler: handleListRoles, 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]">
+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,
+2 -8
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>
);
};
+21 -9
View File
@@ -1,16 +1,28 @@
export const ChevronDownIcon = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width={16} height={16} color={"currentColor"} fill={"none"} {...props}>
<path d="M18 9.00005C18 9.00005 13.5811 15 12 15C10.4188 15 6 9 6 9" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"></path>
</svg>
export const ArrowDown01Icon = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width={64} height={64} color={"currentColor"} fill={"none"} {...props}>
<path d="M5.99977 9.00005L11.9998 15L17.9998 9" stroke="currentColor" strokeWidth="2" strokeMiterlimit="16" strokeLinecap="round" strokeLinejoin="round"></path>
</svg>
);
export const ChevronRightIcon = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width={16} height={16} color={"currentColor"} fill={"none"} {...props}>
<path d="M9 6C9 6 15 10.4189 15 12C15 13.5812 9 18 9 18" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"></path>
</svg>
export const ArrowLeft01Icon = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width={64} height={64} color={"currentColor"} fill={"none"} {...props}>
<path d="M15 6L9 12.0001L15 18" stroke="currentColor" strokeWidth="2" strokeMiterlimit="16" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
export const ArrowRight01Icon = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width={64} height={64} color={"currentColor"} fill={"none"} {...props}>
<path d="M9.00005 6L15 12L9 18" stroke="currentColor" strokeWidth="2" strokeMiterlimit="16" strokeLinecap="round" strokeLinejoin="round"></path>
</svg>
);
export const ArrowUp01Icon = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width={64} height={64} color={"currentColor"} fill={"none"} {...props}>
<path d="M18 15L12 9L6 15" stroke="currentColor" strokeWidth="2" strokeMiterlimit="16" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
export const UserCircle02Icon = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width={64} height={64} color={"currentColor"} fill={"none"} {...props}>
<path fillRule="evenodd" clipRule="evenodd" d="M12 3.20455C7.1424 3.20455 3.20455 7.1424 3.20455 12C3.20455 16.8576 7.1424 20.7955 12 20.7955C16.8576 20.7955 20.7955 16.8576 20.7955 12C20.7955 7.1424 16.8576 3.20455 12 3.20455ZM1.25 12C1.25 6.06294 6.06294 1.25 12 1.25C17.9371 1.25 22.75 6.06294 22.75 12C22.75 17.9371 17.9371 22.75 12 22.75C6.06294 22.75 1.25 17.9371 1.25 12Z" fill="currentColor"></path>
+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';
}
/**