<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know

This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
<!-- END:nextjs-agent-rules -->

# missioflow-SuperAdmin — Guide projet

## Rôle de ce projet

Panel d'administration global du SaaS **missioflow** (gestion d'interventions HVAC multi-tenant). Décision actée 2026-05-09 : **panel semi-autonome, lectures cross-tenant en direct DB, mutations sensibles déléguées au backend**.

### Découpe lectures / mutations (Option B)

Le panel a évolué d'un "client front-end pur" vers un client **autonome en lecture** :

- **Lectures** (listings, dashboards, détails, stats — ~80 % des pages) : Server Components qui interrogent **MySQL directement** via un user dédié `superadmin_ro` avec `GRANT SELECT` (+ `INSERT activities_log`). Plus de dépendance cross-session pour avancer sur les pages de visualisation. Voir `src/lib/db/`.
- **Mutations sensibles** (suspend/resume/cancel tenant, billing Stripe, change plan, opérations qui touchent au modèle métier) : restent côté `missioflow-app` via endpoints HTTP (Bearer JWT super-admin). Pourquoi ? Parce qu'elles déclenchent du business (Stripe webhooks, audit log riche, side-effects sur techniciens/sites). Réinventer ça côté panel = duplication qui divergera.

> Règle d'or révisée : si la donnée est en base et qu'on veut la **lire**, le panel le fait directement. Si on veut la **muter**, on passe toujours par `missioflow-app`. Le schéma DB reste défini dans `missioflow-app/database/schema.sql` ; le panel ne migre jamais.

### Mitigations sécurité non-négociables (Option B)

Décision actée avec ces 3 contraintes :

1. **User MySQL dédié quasi read-only** (`superadmin_ro`). Le droit de **lecture** est un `GRANT SELECT` **global au niveau base** (`GRANT SELECT ON db_missioflow_prod.* TO 'superadmin_ro'@'%'`, vérifié en prod le 2026-06-09, mf_ask #128/#130) : volontaire et assumé, le panel doit pouvoir lire toute table cross-tenant et les nouvelles tables (ex. `pricing_tiers` du chantier per-seat) sans devoir ajouter un grant à chaque fois. Les droits d'**écriture** restent au contraire strictement ciblés : `INSERT activities_log` + UPDATE/INSERT/DELETE limités au *périmètre des mutations admin* ci-dessous. Toute autre table reste en lecture pure. (NB : `superadmin_ro` n'existe que sur la prod ; la demo n'a pas ce user.)
2. **Audit trail systématique** : chaque page panel qui charge de la donnée appelle `logActivity()` (cf. `src/lib/db/audit.ts`). Le journal `activities_log` reste partagé avec le backend.
3. **Paramétrisation SQL stricte** : toute requête utilise des `?` placeholders via `query()` helper (cf. `src/lib/db/connection.ts`). Aucune concat avec input utilisateur. Si on ajoute des filtres dynamiques, ils passent par les placeholders, pas par l'interpolation.

### Périmètre des mutations admin (white-list)

Une mutation depuis le panel n'est autorisée **que si** la table cible est dans cette liste **et** la mitigation associée est respectée. Tout ajout passe par une PR explicite qui met à jour cette liste + le GRANT MySQL.

| Table | Opérations | Justification | Mitigations supplémentaires |
|---|---|---|---|
| `plans` | UPDATE (champs whitelistés ci-dessous) | Référentiel global rare à modifier. Pas de side-effect Stripe sur les abonnements existants. | Audit avant UPDATE avec dump JSON before/changes. Validation côté Route Handler (whitelist des champs, coercion). |
| `activities_log` | INSERT | Cf. mitigation 2 ci-dessus | — |

#### Matrice editable / non-editable des champs `plans` (mf_ask #10, 2026-05-09)

| Champ | UI | Pourquoi |
|---|---|---|
| `name` | ✅ éditable | Affichage seul (page tarifs + email bienvenue) |
| `description` | ✅ éditable (textarea) | Description marketing (page tarifs + Stripe Products côté `bin/setup_stripe.php`) |
| `max_technicians`, `max_sites`, `max_machines`, `max_templates` | ✅ éditable | Quotas lus à chaque requête PHP, applique immédiatement à tous les tenants du plan. Convention `-1 = illimité` |
| `features` (JSON) | ✅ éditable via **checklist** depuis `plan_features_catalog` | Lu à chaque requête. La checklist force à choisir parmi les 17 codes catalogués (pas de feature fantôme) |
| `visible` | ✅ toggle | Cache le plan de la page tarifs publique |
| `monthly_price`, `yearly_price` | 🟡 éditable AVEC warning Stripe | Modifie l'affichage public mais PAS les abonnements Stripe existants ni les nouveaux signups (Stripe Prices immuables par design) |
| `code` | 🔴 **lecture seule** (input disabled) | Renommer après le 1er signup casse `StripeWebhookHandler::handleCheckoutCompleted` qui re-cherche le plan par `code`. Verrouillé sans condition |
| `stripe_price_id_monthly`, `stripe_price_id_yearly` | 🔴 lecture seule (non exposés) | Gérés exclusivement par `bin/setup_stripe.php` côté missioflow-app |
| `id`, `created_at`, `updated_at`, `sort_order` | 🔴 lecture seule (non exposés) | Métadonnées internes |

Tables explicitement **interdites** au panel (mutations restent côté `missioflow-app`) :
- `tenants` (suspend/cancel/reactivate → Stripe webhooks, audit business riche)
- `tenant_subscriptions` (Stripe sub, billing period, idempotence)
- `techniciens` (création/révocation super-admin → flag is_superadmin)
- `interventions`, `rapports`, `factures`, etc. (métier client, pas admin)

Quand une nouvelle mutation admin devient nécessaire, le pattern est :
1. PR ajoute la table au tableau ci-dessus avec sa justification.
2. Migration SQL ajoute le GRANT d'**écriture** ciblé sur cette seule table (jamais de GRANT d'écriture global). À ne pas confondre avec le `SELECT` de lecture qui, lui, est global au niveau base (cf. mitigation 1).
3. Route Handler avec validation whitelist + audit obligatoire avant l'UPDATE.
4. Tests smoke cover le happy path + cas validation rejected + cas non-autorisé (403).

### Durcissement public (en complément du modèle d'auth)

- **IP allowlist** au niveau `proxy.ts` via `SUPERADMIN_IP_ALLOWLIST` (CSV). Vide en dev. En prod : uniquement l'IP fixe de l'admin. Hors-liste ⇒ redirect `/nope`.
- **`/nope`** est une page publique minimaliste neutre (à enrichir avec une "belle image" plus tard).
- **`robots.txt` blanket-disallow** + meta `noindex/nofollow/nocache` sur le layout root. Aucun moteur ne doit indexer l'URL publique.
- **Le DNS `sysop.missioflow.fr` n'apparaît dans aucun lien public** ; le seul moyen de le découvrir est de le connaître.

## Stack

- Next.js 16 (App Router, dossier `src/`, Turbopack)
- TypeScript 5, React 19
- Tailwind CSS 4
- ESLint 9 (`eslint-config-next`)
- Node 22, gestionnaire `npm` (lockfile en place) — `pnpm` accepté à la place

## Politique de dépendances

Vu les attaques récentes sur la supply chain npm, on **n'ajoute pas de dépendance sans nécessité absolue**. Privilégier :

- `fetch` natif plutôt qu'`axios`, `ky` — déjà fait dans `src/lib/api/client.ts`
- Validation manuelle plutôt que `zod`, `yup` pour les formulaires simples
- JWT côté backend uniquement (on stocke + transfère, on ne décode pas) — pas besoin de `jose` ici
- Composants Tailwind purs plutôt qu'une lib UI (`shadcn`, `radix`, etc.)
- Icônes SVG inline plutôt qu'une lib d'icônes

Si on doit absolument ajouter une lib : préférer les très matures (10+ ans, ou outils Vercel/Next.js officiels) et lever explicitement le drapeau dans la PR.

## Backend consommé : missioflow-app

- **Localisation** : `~/projects/missioflow-app` (PHP 8, MySQL 8, multi-tenant)
- **Préfixe API** : `/api/`
- **Routing** : fichiers `public/api/*.php`, dispatch par champ `action` dans le payload POST
- **Documentation** : voir `missioflow-app/CLAUDE.md` et la liste des fichiers dans `missioflow-app/public/api/`

### Authentification

`missioflow-app` accepte trois mécanismes (gérés par `src/middleware/AuthMiddleware.php`) :

1. **Bearer JWT** (`Authorization: Bearer <token>`) — préféré pour clients cross-origin comme celui-ci
2. Session PHP (cookie de session) — same-origin uniquement
3. JWT en cookie — fallback PWA

Côté SuperAdmin : utiliser le JWT en Bearer. Stocker le token dans un cookie `httpOnly` posé par une Route Handler Next.js (ex: `src/app/api/auth/login/route.ts`) qui proxifie l'appel `auth.php`. Cela évite d'exposer le JWT au JavaScript navigateur.

### CORS

Allowlist en dur dans `missioflow-app/src/config/api_config.php` (méthode `setCorsHeaders()`, lignes ~25–98). Pour que ce projet puisse appeler l'API, il faut y ajouter :

- `http://localhost:3000` (dev)
- `https://sysop.missioflow.fr` (prod du panel)

Cette modification se fait dans le repo `missioflow-app`, pas ici.

À noter : comme tous les appels API passent par des Route Handlers Next côté serveur (cf. section authentification), le navigateur n'appelle jamais directement `missioflow-app`. Le CORS reste néanmoins requis pour la phase de dev où on peut vouloir tester en direct.

## Variables d'environnement

Toutes dans `.env.local` (à créer, jamais commité). Voir `.env.example` pour le template à jour.

```
# Backend HTTP (mutations seulement)
API_BASE_URL=http://localhost:8080/api    # prod : https://app.missioflow.fr/api
SESSION_COOKIE_NAME=mf_sysop_session

# DB direct read-only (lectures cross-tenant)
DB_HOST=127.0.0.1
DB_PORT=3306
DB_NAME=missioflow_dev                    # prod : db_missioflow_prod
DB_USER=superadmin_ro
DB_PASSWORD=<rotated_secret>

# Durcissement public
SUPERADMIN_IP_ALLOWLIST=                  # vide en dev ; CSV d'IP en prod
```

`API_BASE_URL` et `DB_*` n'ont **pas** le préfixe `NEXT_PUBLIC_` — le navigateur ne doit ni appeler le backend ni connaître la DB. Tout transite par Server Components / Route Handlers.

Les variables préfixées `NEXT_PUBLIC_` sont exposées au navigateur. Ne jamais y mettre de secret. Pour un secret, utiliser une variable sans préfixe et la lire uniquement dans un Server Component ou une Route Handler.

## Modèle d'authentification SuperAdmin (tranché)

Décision actée le 2026-05-08 : **flag `is_superadmin` sur la table `techniciens` avec `tenant_id NULL`**. La migration côté `missioflow-app` est déjà appliquée (`techniciens.is_superadmin` TINYINT, index `idx_techniciens_superadmin`, contrainte `is_superadmin=1 ⇒ tenant_id=NULL`).

Conséquences pour ce panel :

- Login : la route handler `/api/auth/login` proxifie l'endpoint **`auth.php`** existant (pas d'endpoint super-admin dédié).
- La réponse de `auth.php` doit inclure `is_superadmin` ; sinon refuser la connexion (401).
- Le JWT doit inclure le claim `is_superadmin` (ajustement côté `missioflow-app`).
- Les endpoints super-admin transverses pour les **mutations** restent à créer côté backend — ils détecteront le flag dans le JWT pour bypasser le scoping `tenant_id`. Pour les **lectures**, le panel parle MySQL en direct (cf. *Découpe lectures / mutations*).

## Endpoints à créer côté missioflow-app (MUTATIONS uniquement, post-Option B)

Avec la bascule en Option B (2026-05-09), les listings/dashboards/détails ne nécessitent **plus** d'endpoint dédié — Server Components du panel + DB read-only direct. Ne restent donc à créer que les opérations qui mutent :

```
POST   /api/superadmin/tenants/:id/suspend          # suspend l'accès tenant
POST   /api/superadmin/tenants/:id/resume           # réactive
POST   /api/superadmin/tenants/:id/cancel           # cancellation Stripe + cleanup
POST   /api/superadmin/tenants/:id/change_plan      # change abonnement (Stripe)
POST   /api/superadmin/superadmins                  # créer un autre super-admin
DELETE /api/superadmin/superadmins/:id              # révoquer un super-admin
```

Pourquoi laisser ces endpoints côté backend même avec Option B :
- Stripe webhooks et appels API doivent être centralisés côté `missioflow-app` (clés secrètes, idempotence, retry).
- L'audit log riche pour les mutations (qui a changé quoi, quand, depuis quelle IP) bénéficie d'avoir le code business à proximité.
- La compromission du panel doit rester non-mutating — c'est tout l'enjeu du user MySQL read-only.

Pour les **lectures** (anciennement aussi listées : `GET /api/superadmin/tenants`, `GET /api/superadmin/subscriptions`, `GET /api/superadmin/activities`, `GET /api/superadmin/api_metrics`, `GET /api/superadmin/monitoring`), aucun endpoint à créer — voir `src/lib/db/queries/` côté panel.

## Domaine métier (glossaire FR)

Entités gérées par missioflow, à connaître pour les écrans admin :

| Entité | Description |
|---|---|
| `tenants` | Organisations clientes du SaaS (un tenant = un compte payant) |
| `plans` | Tiers d'abonnement (starter, pro, enterprise) |
| `tenant_subscriptions` | État de facturation/abonnement d'un tenant |
| `techniciens` | Utilisateurs terrain d'un tenant |
| `sites_clients` | Sites/établissements du client final |
| `machines` | Équipements HVAC sur un site |
| `interventions` | Visites / dépannages sur une machine |
| `rapports` | PDF générés via Gotenberg suite à une intervention |
| `factures` | Factures émises par le tenant |
| `devis`, `demandes_devis` | Devis et demandes de devis |
| `clients_portail` | Comptes clients finaux ayant accès au portail |
| `coefficients_gaz` | Référentiel fluides frigorigènes (global, partagé) |
| `report_templates` | Templates PDF (global, configurables par tenant) |
| `activities_log` | Journal d'audit des actions tenant |
| `api_metrics`, `alert_thresholds` | Monitoring de l'API |

Schéma complet : `missioflow-app/database/schema.sql`.

## Conventions

- **Langue** : tout en français côté UI, commentaires métier, messages d'erreur. Code (identifiants, noms de fonctions) reste en anglais.
- **Branches** : `main` par défaut. `feature/<nom>` pour les nouveautés, `fix/<nom>` pour les correctifs.
- **Commits** : messages courts en français, présent narratif (`ajoute le tableau des tenants`, `corrige l'auth JWT en SSR`).
- **Style** : ESLint + config Next. Pas de `any` non justifié.
- **Tests** : à définir (Vitest + Testing Library pour les composants, Playwright pour l'E2E si nécessaire).

## Commandes

```
npm run dev      # serveur de dev (Turbopack) sur http://localhost:3000
npm run build    # build de prod (standalone, cf. next.config.ts)
npm run start    # lance le build de prod
npm run lint     # ESLint
```

## Déploiement

Le panel se déploie sur le VPS OVH (141.94.42.172) à côté des stacks `missioflow-demo` / `missioflow-prod`, via une stack Swarm dédiée `missioflow-superadmin` derrière le même Traefik (wildcard cert `*.missioflow.fr` déjà émis).

- **Hostname public** : `sysop.missioflow.fr` (DNS pointe vers le VPS, cert Let's Encrypt couvert par le wildcard).
- **Stack** : `missioflow-superadmin` (déclarée dans `docker-compose.superadmin.yml`, dossier `/home/ubuntu/missioflow-superadmin/` créé au 1er déploiement).
- **Image** : `missioflow-superadmin:$CI_COMMIT_SHORT_SHA` + alias `:latest`. Build sur le VPS via SSH (calque exact de missioflow-app — pas de registry intermédiaire).
- **Réseaux** : deux overlays externes — `traefik-public` (partagé avec Traefik) **et** `missioflow-prod_prod-net` (réseau interne de la stack `missioflow-prod`, attachable). Cette deuxième attache permet au panel d'atteindre le service MySQL `db` de la stack prod via DNS Docker Swarm (hostname `db`, port `3306`) sans exposer 3306 publiquement. Si la stack `missioflow-prod` n'est pas déployée, le panel boote quand même mais la lecture DB échoue avec une erreur explicite.
- **Pipeline** : `.gitlab-ci.yml` avec étape `test` (lint + build smoke) + étape `deploy_superadmin_vps` (trigger sur push `main`). Le job CI génère `.env.superadmin.secrets` à partir des CI variables (jamais commité, gitignored explicitement) qui voyage dans le tar et est chargé par le script SSH avant le `docker stack deploy`. Cleanup post-deploy.

Variables GitLab CI/CD à configurer dans le repo (Settings → CI/CD → Variables) :

| Variable | Type | Source | Note |
|---|---|---|---|
| `OVH_SSH_HOST` | clair | partagé avec missioflow-app | `141.94.42.172` |
| `OVH_SSH_USER` | clair | partagé | `ubuntu` |
| `OVH_SSH_PRIVATE_KEY_B64` | masked | partagé | clé SSH ubuntu en base64 |
| `SUPERADMIN_DEPLOY_PATH` | clair, optionnel | — | défaut `/home/ubuntu/missioflow-superadmin` |
| `SUPERADMIN_STACK_NAME` | clair, optionnel | — | défaut `missioflow-superadmin` |
| `DB_HOST` | clair | session backend | hostname DNS Swarm du service MySQL prod (ex: `db`) |
| `DB_PORT` | clair, optionnel | — | défaut `3306` |
| `DB_NAME` | clair | session backend | ex: `db_missioflow_prod` |
| `DB_USER` | clair | session backend | `superadmin_ro` (user dédié read-only) |
| `DB_PASSWORD` | **masked** | session backend (canal sécurisé hors-MCP) | mot de passe du user `superadmin_ro` |
| `SUPERADMIN_IP_ALLOWLIST` | clair | — | CSV des IPs autorisées (vide = pas d'allowlist) |
| `MF_INTERNAL_NETWORK` | clair, optionnel | — | nom du network Docker partagé avec missioflow-prod ; défaut `missioflow-prod_prod-net` |

Procédure pour le bootstrap de la prod (chaîne de dépendances) :

1. **Côté missioflow-app** : créer le user MySQL prod (snippet SQL dans le `mf_ask` envoyé à la session app).
2. **Côté GitLab CI variables** : configurer `DB_HOST`, `DB_NAME`, `DB_USER`, `DB_PASSWORD`, et l'IP `SUPERADMIN_IP_ALLOWLIST`.
3. **Push main** : la pipeline regénère `.env.superadmin.secrets` et redéploie. Si toutes les pièces sont en place, la page `/tenants` devient fonctionnelle. Sinon elle affiche un message d'erreur explicite ("Variables DB manquantes" ou "ECONNREFUSED").

Test local de l'image avant push :

```
docker build -t missioflow-superadmin:dev .
docker run --rm -p 3000:3000 -e API_BASE_URL=http://localhost:8080/api missioflow-superadmin:dev
# vérifier : curl -I http://localhost:3000/login → HTTP 200
```

Pour reproduire la stack complète en local : `docker compose -f docker-compose.superadmin.yml up`.

## À ne pas faire

- Ne pas dupliquer la logique métier de `missioflow-app`. Si un calcul existe en PHP, on l'appelle, on ne le ré-implémente pas en TS.
- Ne pas se connecter directement à la base MySQL. Tout passe par l'API.
- Ne pas exposer le JWT au JavaScript navigateur (cookie `httpOnly` uniquement).
- Ne pas mettre de secret dans une variable `NEXT_PUBLIC_*`.
- Ne pas modifier l'allowlist CORS depuis ce repo (la modif appartient à `missioflow-app`).

## Ressources

- Backend : `~/projects/missioflow-app/CLAUDE.md`
- Schéma DB : `~/projects/missioflow-app/database/schema.sql`
- Routes API : `~/projects/missioflow-app/public/api/`
- Middleware auth : `~/projects/missioflow-app/src/middleware/AuthMiddleware.php`
- Config CORS : `~/projects/missioflow-app/src/config/api_config.php`
- Doc Next.js installé : `node_modules/next/dist/docs/`
