refactor: remove module system integration from admin and CLI
Removes all module-related logic from the admin dashboard, CLI database initialization, and AdminPages component: - Drop `initModules` call from `db init` CLI command and simplify the completion message to only reflect core feature tables - Remove `getModuleDashboardStats` and module page routing from admin stats actions and update usage documentation accordingly - Simplify `AdminPagesClient` to remove module page loading, lazy components, and module-specific props (`moduleStats`, `modulePageInfo`, `routeInfo`, `enabledModules`)
This commit is contained in:
+387
@@ -0,0 +1,387 @@
|
|||||||
|
# ZEN — Plan du projet
|
||||||
|
|
||||||
|
> Un CMS Next.js construit sur l'essentiel, rien de plus, rien de moins.
|
||||||
|
|
||||||
|
ZEN est un système de gestion de contenu (CMS) pour Next.js. Il s'installe automatiquement dans n'importe quel projet Next.js via :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx @zen/start
|
||||||
|
```
|
||||||
|
|
||||||
|
Le package principal est `@zen/core`. Il fournit toute l'infrastructure nécessaire pour gérer un site : authentification, base de données, stockage, courriels, paiements, PDF, tâches planifiées, notifications. Chaque fonctionnalité est indépendante — les cores ne se connaissent pas entre eux, mais le reste du CMS s'appuie sur chacun d'eux.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Principes directeurs
|
||||||
|
|
||||||
|
**Core purity** — Chaque core contient uniquement du code propre à son domaine. Aucune logique métier, aucune dépendance vers un autre core.
|
||||||
|
|
||||||
|
**Minimal by default** — Pas de fonctionnalité superflue. Si ce n'est pas nécessaire, ça n'existe pas.
|
||||||
|
|
||||||
|
**Sécuritaire par défaut** — Requêtes paramétrées, protection CSRF, limitation de débit, validation en entrée, erreurs opaques vers le client.
|
||||||
|
|
||||||
|
**Performant** — Connexions en pool, cache HTTP, génération différée des services.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Structure du projet
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
core/ # Infrastructure fondamentale — la base de tout
|
||||||
|
features/ # Fonctionnalités du CMS utilisant les cores
|
||||||
|
shared/ # Utilitaires, composants et styles partagés
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `src/core` — Les piliers du CMS
|
||||||
|
|
||||||
|
Chaque core est une brique indépendante. Il n'existe qu'une seule façon de faire chaque chose dans ZEN : passer par le core concerné. L'ensemble du CMS, des features aux modules, repose sur ces cores.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### API
|
||||||
|
|
||||||
|
**`@zen/core/api`**
|
||||||
|
|
||||||
|
L'API est le point d'entrée unique de toutes les requêtes HTTP du CMS. Que ce soit depuis l'Admin, le front-end du site ou un module tiers, tout passe par là. Il n'existe aucune autre route API dans ZEN.
|
||||||
|
|
||||||
|
Toutes les requêtes arrivent via le catch-all Next.js `app/zen/api/[...path]/route.js`. Le router les dispatche vers le bon handler selon le chemin et la méthode HTTP.
|
||||||
|
|
||||||
|
**Ce qu'il fait :**
|
||||||
|
- Routage dynamique avec paramètres nommés (`:id`) et wildcards (`/**`)
|
||||||
|
- Protection CSRF automatique sur toutes les requêtes mutantes (POST, PUT, PATCH, DELETE)
|
||||||
|
- Limitation de débit par IP avec des préréglages par action (login, register, api)
|
||||||
|
- Injection de session dans le contexte de chaque handler
|
||||||
|
- Trois niveaux d'accès : `public`, `user` (session requise), `admin` (rôle admin requis)
|
||||||
|
- Enregistrement dynamique des routes par les features
|
||||||
|
- Réponses standardisées via `apiSuccess()` et `apiError()`
|
||||||
|
|
||||||
|
**Règle absolue :** Toute route API du CMS doit être enregistrée dans ce router. Jamais de `route.js` parallèle.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Database
|
||||||
|
|
||||||
|
**`@zen/core/database`**
|
||||||
|
|
||||||
|
La couche d'accès à la base de données PostgreSQL. Toute l'application communique avec la base de données uniquement via ce core — jamais directement.
|
||||||
|
|
||||||
|
**Ce qu'il fait :**
|
||||||
|
- Pool de connexions PostgreSQL (max 20 clients, reconnexion automatique)
|
||||||
|
- Fonctions de requête de bas niveau : `query()`, `queryOne()`, `queryAll()`, `transaction()`
|
||||||
|
- Helpers CRUD complets : `create()`, `find()`, `findOne()`, `findById()`, `update()`, `updateById()`, `delete()`, `deleteWhere()`, `count()`, `exists()`
|
||||||
|
- Protection contre l'injection SQL : requêtes paramétrées `$1, $2, ...`, jamais de concaténation
|
||||||
|
- Contrôle d'accès aux colonnes via liste blanche (`allowedColumns`) — empêche le mass assignment
|
||||||
|
- Validation et échappement des identifiants SQL (noms de tables et colonnes)
|
||||||
|
- Gestion SSL configurable : vérification complète en production, souple en développement
|
||||||
|
- Erreurs opaques : seul le code SQLSTATE est exposé, jamais les détails internes
|
||||||
|
|
||||||
|
**Règle absolue :** Aucun code ailleurs dans le CMS ne communique directement avec la base de données. Tout passe par ce core.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Email
|
||||||
|
|
||||||
|
**`@zen/core/email`**
|
||||||
|
|
||||||
|
L'unique système d'envoi de courriels du CMS. Alimenté par Resend. Toute l'application envoie ses courriels via ce core.
|
||||||
|
|
||||||
|
**Ce qu'il fait :**
|
||||||
|
- Envoi de courriels simples : `sendEmail({ to, subject, html })`
|
||||||
|
- Envoi en lot : `sendBatchEmails(emailArray)`
|
||||||
|
- Expéditeur configurable (adresse, nom d'affichage)
|
||||||
|
- Template de base React Email (`BaseLayout`) : logo, marque, pied de page, lien de support
|
||||||
|
- Réponses standardisées `{ success, data, error }`
|
||||||
|
|
||||||
|
**Templates fournis :**
|
||||||
|
- `BaseLayout` — Enveloppe visuelle commune à tous les courriels du CMS (logo, couleurs, pied de page)
|
||||||
|
|
||||||
|
**Règle absolue :** Tout courriel envoyé par le CMS passe par ce core.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Cron
|
||||||
|
|
||||||
|
**`@zen/core/cron`**
|
||||||
|
|
||||||
|
Le registre central de toutes les tâches planifiées. Chaque tâche récurrente de l'application s'enregistre ici — jamais en dehors.
|
||||||
|
|
||||||
|
**Ce qu'il fait :**
|
||||||
|
- Planification de tâches avec expressions cron standard (`schedule(name, expression, handler)`)
|
||||||
|
- Support des fuseaux horaires (défaut : `ZEN_TIMEZONE`)
|
||||||
|
- Survie aux hot reloads Next.js via stockage global (`Symbol.for`)
|
||||||
|
- Remplacement automatique si une tâche du même nom est enregistrée deux fois
|
||||||
|
- Déclenchement manuel : `trigger(name)` pour exécuter une tâche immédiatement
|
||||||
|
- Introspection : `getJobs()`, `getStatus()`, `isRunning(name)`
|
||||||
|
- Gestion des erreurs : un échec de tâche ne plante pas le scheduler
|
||||||
|
|
||||||
|
**Règle absolue :** Toutes les tâches cron de l'application sont enregistrées via ce core.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Storage
|
||||||
|
|
||||||
|
**`@zen/core/storage`**
|
||||||
|
|
||||||
|
La gestion complète du stockage de fichiers, compatible S3 (Cloudflare R2 ou Backblaze B2). Toute l'application lit et écrit des fichiers via ce core.
|
||||||
|
|
||||||
|
**Ce qu'il fait :**
|
||||||
|
- Upload, téléchargement, suppression, copie et déplacement de fichiers
|
||||||
|
- Upload d'images avec cache longue durée (`max-age=31536000`)
|
||||||
|
- Suppression en lot optimisée (S3 batch delete)
|
||||||
|
- URLs pré-signées pour accès direct (GET ou PUT)
|
||||||
|
- Liste paginée des fichiers avec préfixe et continuation token
|
||||||
|
- Signature AWS Signature V4 pour toutes les requêtes
|
||||||
|
- Protection contre le path traversal (`..`, `.`, segments vides, null bytes)
|
||||||
|
- Contrôle d'accès via policies : préfixes publics vs chemins protégés (session + rôle)
|
||||||
|
- Headers de sécurité : `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`
|
||||||
|
- Téléchargement forcé pour les fichiers non-images (prévient l'exécution en navigateur)
|
||||||
|
- Utilitaires : validation de type, validation de taille, nommage unique, extension, MIME type
|
||||||
|
|
||||||
|
**Règle absolue :** Tout accès fichier passe par ce core.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Payments
|
||||||
|
|
||||||
|
**`@zen/core/payments`**
|
||||||
|
|
||||||
|
L'intégration Stripe pour les paiements. Tout ce qui touche à la facturation ou aux transactions dans l'application passe par ce core.
|
||||||
|
|
||||||
|
**Ce qu'il fait :**
|
||||||
|
- Sessions de checkout Stripe (`createCheckoutSession`)
|
||||||
|
- PaymentIntents pour paiements personnalisés (`createPaymentIntent`)
|
||||||
|
- Gestion des clients Stripe (`createCustomer`, `getOrCreateCustomer`)
|
||||||
|
- Récupération des sessions et intentions de paiement
|
||||||
|
- Listing des moyens de paiement d'un client
|
||||||
|
- Vérification des webhooks Stripe (`verifyWebhookSignature`)
|
||||||
|
- Remboursements (`createRefund`)
|
||||||
|
- Initialisation paresseuse du client Stripe (seulement si configuré)
|
||||||
|
- Clé publiable exposée pour le front-end (`getPublishableKey`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### PDF
|
||||||
|
|
||||||
|
**`@zen/core/pdf`**
|
||||||
|
|
||||||
|
La génération de fichiers PDF à partir de composants React. Tout PDF produit par l'application passe par ce core.
|
||||||
|
|
||||||
|
**Ce qu'il fait :**
|
||||||
|
- Rendu de documents PDF depuis des composants React (`renderToBuffer`)
|
||||||
|
- Réexporte l'API complète de `@react-pdf/renderer` : `Document`, `Page`, `View`, `Text`, `Image`, `Link`, `StyleSheet`, `Font`
|
||||||
|
- Utilitaire de nommage : `getFilename(prefix, identifier, date?)` → `"invoice-12345-2024-01-15.pdf"`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Toast
|
||||||
|
|
||||||
|
**`@zen/core/toast`**
|
||||||
|
|
||||||
|
Le système de notifications visuelles de l'application. Que ce soit dans l'Admin ou sur le front-end, c'est l'unique système de toast à utiliser.
|
||||||
|
|
||||||
|
**Ce qu'il fait :**
|
||||||
|
- Contexte React via `<ToastProvider>` et `useToast()`
|
||||||
|
- Quatre types de notifications : `success`, `error`, `warning`, `info`
|
||||||
|
- Disparition automatique avec durées configurables par type
|
||||||
|
- Animation de sortie (fade-out 300ms)
|
||||||
|
- Flag `dismissible` par notification
|
||||||
|
- `<ToastContainer>` pour le rendu dans l'arbre React
|
||||||
|
|
||||||
|
**Règle absolue :** Un seul système de toast dans toute l'application — celui-ci.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `src/features` — Les fonctionnalités du CMS
|
||||||
|
|
||||||
|
Les features utilisent les cores pour implémenter les fonctionnalités centrales du CMS. Elles ont accès à l'API, à la base de données, aux courriels, etc.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Auth
|
||||||
|
|
||||||
|
**`@zen/core/auth`**
|
||||||
|
|
||||||
|
Le système d'authentification du CMS. Toute authentification d'utilisateur dans le site passe par ici.
|
||||||
|
|
||||||
|
**Ce qu'il fait :**
|
||||||
|
- Inscription et connexion d'utilisateurs
|
||||||
|
- Hachage des mots de passe avec `scrypt` (natif Node.js) + sel aléatoire + comparaison résistante aux timing attacks
|
||||||
|
- Gestion de sessions : création, validation, suppression, rafraîchissement automatique (<20 jours → 30 jours)
|
||||||
|
- Vérification d'adresse courriel par token
|
||||||
|
- Réinitialisation de mot de passe par lien sécurisé
|
||||||
|
- Middleware de protection de routes : `protect()`, `checkAuth()`, `requireRole()`
|
||||||
|
- Server Actions : `loginAction`, `registerAction`, `logoutAction`, `forgotPasswordAction`, `resetPasswordAction`, `verifyEmailAction`
|
||||||
|
- Tables gérées : `zen_auth_users`, `zen_auth_sessions`, `zen_auth_email_verifications`, `zen_auth_password_resets`
|
||||||
|
|
||||||
|
**Règle absolue :** Toute authentification de site passe par cette feature.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Admin
|
||||||
|
|
||||||
|
**`@zen/core/admin`**
|
||||||
|
|
||||||
|
L'interface d'administration centrale. Tableau de bord visuel pour gérer le site et ses modules.
|
||||||
|
|
||||||
|
**Ce qu'il fait :**
|
||||||
|
- Protection des routes admin : `protectAdmin()`, `isAdmin()`
|
||||||
|
- Pages catch-all pour l'interface admin (`AdminPagesClient`, `AdminPagesLayout`)
|
||||||
|
- Navigation construite côté serveur (`buildNavigationSections`)
|
||||||
|
- Gestion des utilisateurs depuis l'interface
|
||||||
|
|
||||||
|
**Navigation :**
|
||||||
|
- Tableau de bord → `/admin/dashboard`
|
||||||
|
- Utilisateurs → `/admin/users`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Provider
|
||||||
|
|
||||||
|
**`@zen/core/provider`**
|
||||||
|
|
||||||
|
Le provider React racine du CMS. Il s'insère dans le layout du site et active tout ce dont le CMS a besoin côté client.
|
||||||
|
|
||||||
|
**Ce qu'il fait :**
|
||||||
|
- Enveloppe l'application dans `<ToastProvider>` avec son `<ToastContainer>`
|
||||||
|
- Un seul composant à poser dans le layout : `<ZenProvider>`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `src/shared` — Utilitaires partagés
|
||||||
|
|
||||||
|
Tout ce qui est utile à travers le CMS sans appartenir à un core ou une feature.
|
||||||
|
|
||||||
|
### Composants UI (`src/shared/components/`)
|
||||||
|
|
||||||
|
Bibliothèque de composants React stylisés, utilisés dans l'Admin et les pages du CMS :
|
||||||
|
|
||||||
|
`Badge`, `StatusBadge`, `TypeBadge`, `Button`, `Card`, `Input`, `Loading`, `LoadingState`, `Modal`, `Pagination`, `Select`, `StatCard`, `Table`, `Textarea`, `MarkdownEditor`, `PasswordStrengthIndicator`, `FilterTabs`, `Breadcrumb`
|
||||||
|
|
||||||
|
### Utilitaires (`src/shared/lib/`, `src/shared/utils/`)
|
||||||
|
|
||||||
|
- **`appConfig`** — Lecture centralisée de la configuration (`getAppName`, `getAppConfig`, `getPublicBaseUrl`)
|
||||||
|
- **`logger`** — Console stylisée pour les logs (`step`, `done`, `warn`, `fail`, `info`)
|
||||||
|
- **`dates`** — Manipulation de dates en UTC (`formatDateForDisplay`, `getDaysBetween`, `isOverdue`, etc.)
|
||||||
|
- **`metadata`** — Génération de métadonnées Next.js (`generateMetadata`, `generateTitle`, `generateRobots`)
|
||||||
|
- **`rateLimit`** — Limitation de débit partagée (`checkRateLimit`) avec préréglages par action
|
||||||
|
- **`currency`** — Formatage monétaire (`formatCurrency`, `getCurrencySymbol`)
|
||||||
|
|
||||||
|
### Icons (`src/shared/Icons.js`)
|
||||||
|
|
||||||
|
Bibliothèque de plus de 1000 icônes (style Untitled UI).
|
||||||
|
|
||||||
|
### Styles (`src/shared/styles/zen.css`)
|
||||||
|
|
||||||
|
Feuille de style CSS de base du CMS.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Initialisation
|
||||||
|
|
||||||
|
Dans `instrumentation.js` du projet Next.js :
|
||||||
|
|
||||||
|
```js
|
||||||
|
export async function register() {
|
||||||
|
if (process.env.NEXT_RUNTIME === 'nodejs') {
|
||||||
|
const { initializeZen } = await import('@zen/core');
|
||||||
|
await initializeZen();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Dans `app/layout.js` :
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
import { ZenProvider } from '@zen/core/provider';
|
||||||
|
|
||||||
|
export default function RootLayout({ children }) {
|
||||||
|
return (
|
||||||
|
<html>
|
||||||
|
<body>
|
||||||
|
<ZenProvider>
|
||||||
|
{children}
|
||||||
|
</ZenProvider>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Dans `app/zen/api/[...path]/route.js` :
|
||||||
|
|
||||||
|
```js
|
||||||
|
export { GET, POST, PUT, PATCH, DELETE } from '@zen/core/zen/api';
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Variables d'environnement
|
||||||
|
|
||||||
|
| Variable | Description |
|
||||||
|
|---|---|
|
||||||
|
| `ZEN_NAME` | Nom de l'application |
|
||||||
|
| `ZEN_TIMEZONE` | Fuseau horaire IANA (défaut : `America/Toronto`) |
|
||||||
|
| `ZEN_CURRENCY` | Code monétaire (défaut : `CAD`) |
|
||||||
|
| `ZEN_CURRENCY_SYMBOL` | Symbole monétaire (défaut : `$`) |
|
||||||
|
| `NEXT_PUBLIC_URL` | URL de base (production) |
|
||||||
|
| `NEXT_PUBLIC_URL_DEV` | URL de base (développement) |
|
||||||
|
| `ZEN_DATABASE_URL` | Chaîne de connexion PostgreSQL (production) |
|
||||||
|
| `ZEN_DATABASE_URL_DEV` | Chaîne de connexion PostgreSQL (développement) |
|
||||||
|
| `ZEN_DB_SSL_DISABLED` | Désactiver TLS (local uniquement) |
|
||||||
|
| `ZEN_EMAIL_RESEND_APIKEY` | Clé API Resend |
|
||||||
|
| `ZEN_EMAIL_FROM_ADDRESS` | Adresse d'expédition |
|
||||||
|
| `ZEN_EMAIL_FROM_NAME` | Nom d'affichage de l'expéditeur |
|
||||||
|
| `ZEN_STORAGE_PROVIDER` | `r2` ou `backblaze` |
|
||||||
|
| `ZEN_STORAGE_ENDPOINT` | Endpoint S3-compatible |
|
||||||
|
| `ZEN_STORAGE_ACCESS_KEY` | Clé d'accès stockage |
|
||||||
|
| `ZEN_STORAGE_SECRET_KEY` | Clé secrète stockage |
|
||||||
|
| `ZEN_STORAGE_BUCKET` | Nom du bucket |
|
||||||
|
| `STRIPE_SECRET_KEY` | Clé secrète Stripe |
|
||||||
|
| `STRIPE_PUBLISHABLE_KEY` | Clé publiable Stripe |
|
||||||
|
| `STRIPE_WEBHOOK_SECRET` | Secret webhook Stripe |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CLI base de données
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx zen-db init # Créer toutes les tables
|
||||||
|
npx zen-db test # Tester la connexion
|
||||||
|
npx zen-db drop # Supprimer toutes les tables (confirmation requise)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Flux d'une requête
|
||||||
|
|
||||||
|
```
|
||||||
|
Navigateur / Client
|
||||||
|
↓
|
||||||
|
app/zen/api/[...path]/route.js ← catch-all Next.js
|
||||||
|
↓
|
||||||
|
core/api — router.js ← CSRF, rate limit, auth
|
||||||
|
↓
|
||||||
|
Handler (feature) ← logique métier
|
||||||
|
↓
|
||||||
|
core/database, core/storage, ← accès aux ressources
|
||||||
|
core/email, core/payments…
|
||||||
|
↓
|
||||||
|
Réponse standardisée ← apiSuccess() / apiError()
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Résumé des règles absolues
|
||||||
|
|
||||||
|
| Domaine | Règle |
|
||||||
|
|---|---|
|
||||||
|
| API | Toutes les routes HTTP passent par `core/api`. Aucune autre route API. |
|
||||||
|
| Base de données | Tout accès DB passe par `core/database`. Jamais de requêtes directes. |
|
||||||
|
| Courriels | Tout envoi de courriel passe par `core/email`. |
|
||||||
|
| Cron | Toutes les tâches planifiées s'enregistrent dans `core/cron`. |
|
||||||
|
| Stockage | Tout accès fichier passe par `core/storage`. |
|
||||||
|
| Notifications | Un seul système de toast dans toute l'app : `core/toast`. |
|
||||||
|
| Authentification | Toute auth de site passe par `features/auth`. |
|
||||||
+1
-12
@@ -69,18 +69,7 @@ async function runCLI() {
|
|||||||
const { initFeatures } = await import('../features/init.js');
|
const { initFeatures } = await import('../features/init.js');
|
||||||
const featuresResult = await initFeatures();
|
const featuresResult = await initFeatures();
|
||||||
|
|
||||||
// Module tables are initialized per-module, if present
|
done(`DB ready — ${featuresResult.created.length} tables created, ${featuresResult.skipped.length} skipped`);
|
||||||
let modulesResult = { created: [], skipped: [] };
|
|
||||||
try {
|
|
||||||
const { initModules } = await import('../modules/init.js');
|
|
||||||
modulesResult = await initModules();
|
|
||||||
} catch {
|
|
||||||
// Modules may not be present in all project setups — silently skip
|
|
||||||
}
|
|
||||||
|
|
||||||
const totalCreated = featuresResult.created.length + modulesResult.created.length;
|
|
||||||
const totalSkipped = featuresResult.skipped.length + modulesResult.skipped.length;
|
|
||||||
done(`DB ready — ${totalCreated} tables created, ${totalSkipped} skipped`);
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,34 +1,26 @@
|
|||||||
/**
|
/**
|
||||||
* Admin Stats Actions
|
* Admin Stats Actions
|
||||||
* Server-side actions for core dashboard statistics
|
* Server-side actions for core dashboard statistics
|
||||||
*
|
*
|
||||||
* Module-specific stats are handled by each module's dashboard actions.
|
|
||||||
* See src/modules/{module}/dashboard/statsActions.js
|
|
||||||
*
|
|
||||||
* Usage in your Next.js app:
|
* Usage in your Next.js app:
|
||||||
*
|
*
|
||||||
* ```javascript
|
* ```javascript
|
||||||
* // app/(admin)/admin/[...admin]/page.js
|
* // app/(admin)/admin/[...admin]/page.js
|
||||||
* import { protectAdmin } from '@zen/core/admin';
|
* import { protectAdmin } from '@zen/core/admin';
|
||||||
* import { getDashboardStats, getModuleDashboardStats } from '@zen/core/admin/actions';
|
* import { getDashboardStats } from '@zen/core/admin/actions';
|
||||||
* import { AdminPagesClient } from '@zen/core/admin/pages';
|
* import { AdminPagesClient } from '@zen/core/admin/pages';
|
||||||
*
|
*
|
||||||
* export default async function AdminPage({ params }) {
|
* export default async function AdminPage({ params }) {
|
||||||
* const { user } = await protectAdmin();
|
* const { user } = await protectAdmin();
|
||||||
*
|
*
|
||||||
* // Fetch core dashboard stats
|
|
||||||
* const statsResult = await getDashboardStats();
|
* const statsResult = await getDashboardStats();
|
||||||
* const dashboardStats = statsResult.success ? statsResult.stats : null;
|
* const dashboardStats = statsResult.success ? statsResult.stats : null;
|
||||||
*
|
*
|
||||||
* // Fetch module dashboard stats (for dynamic widgets)
|
|
||||||
* const moduleStats = await getModuleDashboardStats();
|
|
||||||
*
|
|
||||||
* return (
|
* return (
|
||||||
* <AdminPagesClient
|
* <AdminPagesClient
|
||||||
* params={params}
|
* params={params}
|
||||||
* user={user}
|
* user={user}
|
||||||
* dashboardStats={dashboardStats}
|
* dashboardStats={dashboardStats}
|
||||||
* moduleStats={moduleStats}
|
|
||||||
* />
|
* />
|
||||||
* );
|
* );
|
||||||
* }
|
* }
|
||||||
@@ -72,9 +64,9 @@ export async function getDashboardStats() {
|
|||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
fail(`Error getting dashboard stats: ${error.message}`);
|
fail(`Error getting dashboard stats: ${error.message}`);
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: error.message || 'Failed to get dashboard statistics'
|
error: error.message || 'Failed to get dashboard statistics'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,85 +1,24 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
/**
|
|
||||||
* Admin Pages Component
|
|
||||||
*
|
|
||||||
* This component handles both core admin pages and module pages.
|
|
||||||
* Module pages are loaded dynamically on the client where hooks work properly.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Suspense } from 'react';
|
|
||||||
import DashboardPage from './pages/DashboardPage.js';
|
import DashboardPage from './pages/DashboardPage.js';
|
||||||
import UsersPage from './pages/UsersPage.js';
|
import UsersPage from './pages/UsersPage.js';
|
||||||
import UserEditPage from './pages/UserEditPage.js';
|
import UserEditPage from './pages/UserEditPage.js';
|
||||||
import ProfilePage from './pages/ProfilePage.js';
|
import ProfilePage from './pages/ProfilePage.js';
|
||||||
import { getModulePageLoader } from '../../../modules/modules.pages.js';
|
|
||||||
|
|
||||||
// Loading component for suspense
|
export default function AdminPagesClient({ params, user, dashboardStats = null }) {
|
||||||
function PageLoading() {
|
const parts = params?.admin || [];
|
||||||
return (
|
const page = parts[0] || 'dashboard';
|
||||||
<div className="flex items-center justify-center min-h-[400px]">
|
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white"></div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AdminPagesClient({
|
if (page === 'users' && parts[1] === 'edit' && parts[2]) {
|
||||||
params,
|
return <UserEditPage userId={parts[2]} user={user} />;
|
||||||
user,
|
|
||||||
dashboardStats = null,
|
|
||||||
moduleStats = {},
|
|
||||||
modulePageInfo = null,
|
|
||||||
routeInfo = null,
|
|
||||||
enabledModules = {}
|
|
||||||
}) {
|
|
||||||
// If this is a module page, render it with lazy loading
|
|
||||||
if (modulePageInfo && routeInfo) {
|
|
||||||
const LazyComponent = getModulePageLoader(modulePageInfo.module, modulePageInfo.path);
|
|
||||||
if (LazyComponent) {
|
|
||||||
// Build props for the page
|
|
||||||
const pageProps = { user };
|
|
||||||
if (routeInfo.action === 'edit' && routeInfo.id) {
|
|
||||||
// Add ID props for edit pages (modules may use different prop names)
|
|
||||||
pageProps.id = routeInfo.id;
|
|
||||||
pageProps.invoiceId = routeInfo.id;
|
|
||||||
pageProps.clientId = routeInfo.id;
|
|
||||||
pageProps.itemId = routeInfo.id;
|
|
||||||
pageProps.categoryId = routeInfo.id;
|
|
||||||
pageProps.transactionId = routeInfo.id;
|
|
||||||
pageProps.recurrenceId = routeInfo.id;
|
|
||||||
pageProps.templateId = routeInfo.id;
|
|
||||||
pageProps.postId = routeInfo.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Suspense fallback={<PageLoading />}>
|
|
||||||
<LazyComponent {...pageProps} />
|
|
||||||
</Suspense>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine core page from routeInfo or params
|
|
||||||
let currentPage = 'dashboard';
|
|
||||||
if (routeInfo?.path) {
|
|
||||||
const parts = routeInfo.path.split('/').filter(Boolean);
|
|
||||||
currentPage = parts[1] || 'dashboard'; // /admin/[page]
|
|
||||||
} else if (params?.admin) {
|
|
||||||
currentPage = params.admin[0] || 'dashboard';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Core page components mapping (non-module pages)
|
|
||||||
const usersPageComponent = routeInfo?.action === 'edit' && routeInfo?.id
|
|
||||||
? () => <UserEditPage userId={routeInfo.id} user={user} enabledModules={enabledModules} />
|
|
||||||
: () => <UsersPage user={user} />;
|
|
||||||
|
|
||||||
const corePages = {
|
const corePages = {
|
||||||
dashboard: () => <DashboardPage user={user} stats={dashboardStats} moduleStats={moduleStats} enabledModules={enabledModules} />,
|
dashboard: () => <DashboardPage user={user} stats={dashboardStats} />,
|
||||||
users: usersPageComponent,
|
users: () => <UsersPage user={user} />,
|
||||||
profile: () => <ProfilePage user={user} />,
|
profile: () => <ProfilePage user={user} />,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Render the appropriate core page or default to dashboard
|
const CorePageComponent = corePages[page];
|
||||||
const CorePageComponent = corePages[currentPage];
|
return CorePageComponent ? <CorePageComponent /> : <DashboardPage user={user} stats={dashboardStats} />;
|
||||||
return CorePageComponent ? <CorePageComponent /> : <DashboardPage user={user} stats={dashboardStats} moduleStats={moduleStats} enabledModules={enabledModules} />;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,33 +1,11 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
/**
|
|
||||||
* Admin Dashboard Page
|
|
||||||
* Displays core stats and dynamically loads module dashboard widgets
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Suspense } from 'react';
|
|
||||||
import { StatCard } from '../../../../shared/components';
|
import { StatCard } from '../../../../shared/components';
|
||||||
import { UserMultiple02Icon } from '../../../../shared/Icons.js';
|
import { UserMultiple02Icon } from '../../../../shared/Icons.js';
|
||||||
import { getModuleDashboardWidgets } from '../../../../modules/modules.pages.js';
|
|
||||||
|
|
||||||
/**
|
export default function DashboardPage({ user, stats }) {
|
||||||
* Loading placeholder for widgets
|
|
||||||
*/
|
|
||||||
function WidgetLoading() {
|
|
||||||
return (
|
|
||||||
<div className="animate-pulse bg-neutral-200 dark:bg-neutral-800 rounded-lg h-32"></div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function DashboardPage({ user, stats, moduleStats = {}, enabledModules = {} }) {
|
|
||||||
const loading = !stats;
|
const loading = !stats;
|
||||||
|
|
||||||
// Get only enabled module dashboard widgets
|
|
||||||
const allModuleWidgets = getModuleDashboardWidgets();
|
|
||||||
const moduleWidgets = Object.fromEntries(
|
|
||||||
Object.entries(allModuleWidgets).filter(([moduleName]) => enabledModules[moduleName])
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4 sm:gap-6 lg:gap-8">
|
<div className="flex flex-col gap-4 sm:gap-6 lg:gap-8">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -40,16 +18,6 @@ export default function DashboardPage({ user, stats, moduleStats = {}, enabledMo
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-6">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-6">
|
||||||
{/* Module dashboard widgets (dynamically loaded) */}
|
|
||||||
{Object.entries(moduleWidgets).map(([moduleName, widgets]) => (
|
|
||||||
widgets.map((Widget, index) => (
|
|
||||||
<Suspense key={`${moduleName}-widget-${index}`} fallback={<WidgetLoading />}>
|
|
||||||
<Widget stats={moduleStats[moduleName]} />
|
|
||||||
</Suspense>
|
|
||||||
))
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* Core stats - always shown */}
|
|
||||||
<StatCard
|
<StatCard
|
||||||
title="Nombre d'utilisateurs"
|
title="Nombre d'utilisateurs"
|
||||||
value={loading ? '-' : String(stats?.totalUsers || 0)}
|
value={loading ? '-' : String(stats?.totalUsers || 0)}
|
||||||
|
|||||||
@@ -9,20 +9,17 @@ import { useToast } from '@zen/core/toast';
|
|||||||
* User Edit Page Component
|
* User Edit Page Component
|
||||||
* Page for editing an existing user (admin only)
|
* Page for editing an existing user (admin only)
|
||||||
*/
|
*/
|
||||||
const UserEditPage = ({ userId, user, enabledModules = {} }) => {
|
const UserEditPage = ({ userId, user }) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const clientsModuleActive = Boolean(enabledModules?.clients);
|
|
||||||
|
|
||||||
const [userData, setUserData] = useState(null);
|
const [userData, setUserData] = useState(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [clients, setClients] = useState([]);
|
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
name: '',
|
name: '',
|
||||||
role: 'user',
|
role: 'user',
|
||||||
email_verified: 'false',
|
email_verified: 'false',
|
||||||
client_id: ''
|
|
||||||
});
|
});
|
||||||
const [errors, setErrors] = useState({});
|
const [errors, setErrors] = useState({});
|
||||||
|
|
||||||
@@ -40,15 +37,6 @@ const UserEditPage = ({ userId, user, enabledModules = {} }) => {
|
|||||||
loadUser();
|
loadUser();
|
||||||
}, [userId]);
|
}, [userId]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (clientsModuleActive) {
|
|
||||||
fetch('/zen/api/admin/clients?limit=500', { credentials: 'include' })
|
|
||||||
.then(res => res.json())
|
|
||||||
.then(data => data.clients ? setClients(data.clients) : setClients([]))
|
|
||||||
.catch(() => setClients([]));
|
|
||||||
}
|
|
||||||
}, [clientsModuleActive]);
|
|
||||||
|
|
||||||
const loadUser = async () => {
|
const loadUser = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -64,7 +52,6 @@ const UserEditPage = ({ userId, user, enabledModules = {} }) => {
|
|||||||
name: data.user.name || '',
|
name: data.user.name || '',
|
||||||
role: data.user.role || 'user',
|
role: data.user.role || 'user',
|
||||||
email_verified: data.user.email_verified ? 'true' : 'false',
|
email_verified: data.user.email_verified ? 'true' : 'false',
|
||||||
client_id: data.linkedClient ? String(data.linkedClient.id) : ''
|
|
||||||
}));
|
}));
|
||||||
} else {
|
} else {
|
||||||
toast.error(data.message || 'Utilisateur introuvable');
|
toast.error(data.message || 'Utilisateur introuvable');
|
||||||
@@ -107,7 +94,6 @@ const UserEditPage = ({ userId, user, enabledModules = {} }) => {
|
|||||||
name: formData.name.trim(),
|
name: formData.name.trim(),
|
||||||
role: formData.role,
|
role: formData.role,
|
||||||
email_verified: formData.email_verified === 'true',
|
email_verified: formData.email_verified === 'true',
|
||||||
...(clientsModuleActive && { client_id: formData.client_id ? parseInt(formData.client_id, 10) : null })
|
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
@@ -209,21 +195,6 @@ const UserEditPage = ({ userId, user, enabledModules = {} }) => {
|
|||||||
onChange={(value) => handleInputChange('email_verified', value)}
|
onChange={(value) => handleInputChange('email_verified', value)}
|
||||||
options={emailVerifiedOptions}
|
options={emailVerifiedOptions}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{clientsModuleActive && (
|
|
||||||
<Select
|
|
||||||
label="Client associé"
|
|
||||||
value={formData.client_id}
|
|
||||||
onChange={(value) => handleInputChange('client_id', value)}
|
|
||||||
options={[
|
|
||||||
{ value: '', label: 'Aucun' },
|
|
||||||
...clients.map(c => ({
|
|
||||||
value: String(c.id),
|
|
||||||
label: [c.client_number, c.company_name || [c.first_name, c.last_name].filter(Boolean).join(' ') || c.email].filter(Boolean).join(' – ')
|
|
||||||
}))
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -1,16 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* Admin Navigation Builder (Server-Only)
|
* Admin Navigation Builder (Server-Only)
|
||||||
*
|
*
|
||||||
* This file imports from the module registry and should ONLY be used on the server.
|
|
||||||
* It builds the complete navigation including dynamic module navigation.
|
|
||||||
*
|
|
||||||
* IMPORTANT: This file is NOT bundled to ensure it shares the same registry instance
|
|
||||||
* that was populated during module discovery.
|
|
||||||
*
|
|
||||||
* IMPORTANT: We import from '@zen/core' (main package) to use the same registry
|
|
||||||
* instance that was populated during initializeZen(). DO NOT import from
|
|
||||||
* '@zen/core/core/modules' as that's a separate bundle with its own registry.
|
|
||||||
*
|
|
||||||
* IMPORTANT: Navigation data must be serializable (no functions/components).
|
* IMPORTANT: Navigation data must be serializable (no functions/components).
|
||||||
* Icons are passed as string names and resolved on the client.
|
* Icons are passed as string names and resolved on the client.
|
||||||
*/
|
*/
|
||||||
@@ -18,7 +8,7 @@
|
|||||||
/**
|
/**
|
||||||
* Build complete navigation sections
|
* Build complete navigation sections
|
||||||
* @param {string} pathname - Current pathname
|
* @param {string} pathname - Current pathname
|
||||||
* @returns {Array} Complete navigation sections (serializable, icons as strings)
|
* @returns {Array} Navigation sections (serializable, icons as strings)
|
||||||
*/
|
*/
|
||||||
export function buildNavigationSections(pathname) {
|
export function buildNavigationSections(pathname) {
|
||||||
const coreNavigation = [
|
const coreNavigation = [
|
||||||
@@ -36,7 +26,7 @@ export function buildNavigationSections(pathname) {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
const systemNavigation = [
|
const systemNavigation = [
|
||||||
{
|
{
|
||||||
id: 'users',
|
id: 'users',
|
||||||
@@ -52,6 +42,6 @@ export function buildNavigationSections(pathname) {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
return [...coreNavigation, ...systemNavigation];
|
return [...coreNavigation, ...systemNavigation];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,41 +12,6 @@ import { getDashboardStats } from '@zen/core/admin/actions';
|
|||||||
import { logoutAction } from '@zen/core/auth/actions';
|
import { logoutAction } from '@zen/core/auth/actions';
|
||||||
import { getAppName } from '@zen/core';
|
import { getAppName } from '@zen/core';
|
||||||
|
|
||||||
function parseAdminRoute(params) {
|
|
||||||
const parts = params?.admin || [];
|
|
||||||
|
|
||||||
if (parts.length === 0) {
|
|
||||||
return { path: '/admin/dashboard', action: null, id: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
const corePages = ['dashboard', 'users', 'profile'];
|
|
||||||
if (corePages.includes(parts[0])) {
|
|
||||||
if (parts[0] === 'users' && parts[1] === 'edit' && parts[2]) {
|
|
||||||
return { path: '/admin/users', action: 'edit', id: parts[2] };
|
|
||||||
}
|
|
||||||
return { path: `/admin/${parts[0]}`, action: null, id: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
let pathParts = [];
|
|
||||||
let action = null;
|
|
||||||
let id = null;
|
|
||||||
const actionKeywords = ['new', 'create', 'edit'];
|
|
||||||
|
|
||||||
for (let i = 0; i < parts.length; i++) {
|
|
||||||
const part = parts[i];
|
|
||||||
if (actionKeywords.includes(part)) {
|
|
||||||
action = part === 'create' ? 'new' : part;
|
|
||||||
if (action === 'edit' && i + 1 < parts.length) {
|
|
||||||
id = parts[i + 1];
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
pathParts.push(part);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { path: '/admin/' + pathParts.join('/') + (action ? '/' + action : ''), action, id };
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function AdminPage({ params }) {
|
export default async function AdminPage({ params }) {
|
||||||
const resolvedParams = await params;
|
const resolvedParams = await params;
|
||||||
const session = await protectAdmin();
|
const session = await protectAdmin();
|
||||||
@@ -56,7 +21,6 @@ export default async function AdminPage({ params }) {
|
|||||||
const dashboardStats = statsResult.success ? statsResult.stats : null;
|
const dashboardStats = statsResult.success ? statsResult.stats : null;
|
||||||
|
|
||||||
const navigationSections = buildNavigationSections('/');
|
const navigationSections = buildNavigationSections('/');
|
||||||
const { path, action, id } = parseAdminRoute(resolvedParams);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AdminPagesLayout
|
<AdminPagesLayout
|
||||||
@@ -69,7 +33,6 @@ export default async function AdminPage({ params }) {
|
|||||||
params={resolvedParams}
|
params={resolvedParams}
|
||||||
user={session.user}
|
user={session.user}
|
||||||
dashboardStats={dashboardStats}
|
dashboardStats={dashboardStats}
|
||||||
routeInfo={{ path, action, id }}
|
|
||||||
/>
|
/>
|
||||||
</AdminPagesLayout>
|
</AdminPagesLayout>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,31 +1,8 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useRef } from 'react';
|
|
||||||
import { ToastProvider, ToastContainer } from '@zen/core/toast';
|
import { ToastProvider, ToastContainer } from '@zen/core/toast';
|
||||||
import { registerExternalModulePages } from '../../modules/modules.pages.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* ZenProvider — root client provider for the ZEN CMS.
|
|
||||||
*
|
|
||||||
* Pass external module configs via the `modules` prop so their
|
|
||||||
* admin pages and public pages are available to the client router.
|
|
||||||
*
|
|
||||||
* @param {Object} props
|
|
||||||
* @param {Array} props.modules - External module configs from zen.config.js
|
|
||||||
* @param {ReactNode} props.children
|
|
||||||
*/
|
|
||||||
export function ZenProvider({ modules = [], children }) {
|
|
||||||
const registered = useRef(false);
|
|
||||||
|
|
||||||
if (!registered.current) {
|
|
||||||
// Register synchronously on first render so pages are available
|
|
||||||
// before any child component resolves a module route.
|
|
||||||
if (modules.length > 0) {
|
|
||||||
registerExternalModulePages(modules);
|
|
||||||
}
|
|
||||||
registered.current = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
export function ZenProvider({ children }) {
|
||||||
return (
|
return (
|
||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
Reference in New Issue
Block a user