---
title: "Utilisateurs"
description: "Contrairement au reste de /admin/* (token statique seul), toute la surface /admin/users/* accepte deux types d'identité (cf. AdminOrStaffAuthenticationMidd…"
---

## Surface `/admin/users/*` — authentification HYBRIDE & traçabilité

Contrairement au reste de `/admin/*` (token statique seul), **toute** la surface
`/admin/users/*` accepte **deux** types d'identité (cf. `AdminOrStaffAuthenticationMiddleware`) :

- **Token statique** `ADMIN_API_TOKEN` (service-to-service / Talend) → autorisé,
  **acteur anonyme** (« système ») : les colonnes acteur du journal restent `NULL`.
- **Token staff nominatif** (cf. `POST /admin/staff/login`) → autorisé, **acteur
  attribué** : l'opérateur (`staffId` + `staffUsername`) est journalisé.

Toute **mutation** d'un compte (`profile`, `verified`, `ban`, `unban`, `anonymize`,
`avatar.set`/`avatar.delete`, `cover.set`/`cover.delete`)
écrit une ligne dans le journal `hxa_bo.user_action_log`, lisible via
`GET /admin/users/{hex}/history`. Le journal est **fail-soft** : un échec d'écriture
d'audit ne fait jamais échouer l'action admin sous-jacente. Seules les transitions
**effectives** sont journalisées (un no-op idempotent `transition: "none"` n'écrit rien).

> ⚠️ Ce middleware ne prouve que **l'identité**, il n'applique **aucun** rôle minimum
> (RBAC) : n'importe quel staff actif (même consultant) est accepté, exactement comme
> le token statique tout-puissant. Le gating par rôle est un suivi délibéré.

Le champ `reason` (string libre, optionnel) est accepté dans le body de **toutes** les
mutations et journalisé tel quel (tronqué à 500 caractères).

---


## `GET /admin/users`

Scan paginé (keyset) de la table `user` pour **enquêter sur n'importe quel compte sans passer par l'API publique filtrée**. Aucun filtrage de visibilité implicite : les comptes bannis, non confirmés et soft-deleted sont **tous** renvoyés. Données brutes (e-mail, statut, dates internes…) ; seul le hash de mot de passe n'est jamais exposé.

**Query params**

| Param        | Valeurs            | Défaut | Notes |
|--------------|--------------------|--------|-------|
| `confirmed`  | `true` \| `false`  | _aucun_ | `confirmed_at IS [NOT] NULL`. Valeur inconnue = filtre ignoré. |
| `banned`     | `true` \| `false`  | _aucun_ | Ban **actif** (`banned_until > NOW()`), pas la simple présence d'une empreinte. |
| `verified`   | `true` \| `false`  | _aucun_ | `is_verified`. |
| `deleted`    | `true` \| `false`  | _aucun_ | `deleted_at IS [NOT] NULL` (soft-delete RGPD). |
| `limit`      | `1..100`           | `50`   | Borné en dur côté serveur. |
| `cursorAt`   | ISO-8601 datetime  | _aucun_ | `joined_at` du dernier item de la page précédente. À fournir avec `cursorId` (les deux ou aucun). |
| `cursorId`   | hex (32 chars)     | _aucun_ | id du dernier item — discriminant pour les `joined_at` identiques. |

Tri implicite : `joined_at DESC, id DESC` (inscriptions les plus récentes d'abord).

**Réponse (200)**

```jsonc
{
  "items": [
    {
      "id":                 "d26d1600cde54bd095e09f8b68ace05f",
      "username":           "alice",
      "qrcodeUrl":          "https://hexatrip.dev.com/qrcode/alice.png",
      "nickname":           "Alice",
      "email":              "alice@example.com",
      "name":               "Doe",
      "firstname":          "Alice",
      "sex":                1,            // valeur DB brute (INT), pas le slug i18n
      "birthdate":          "1990-05-12",
      "birthplaceCityId":   "ab…",        // hex ou null
      "userType":           0,
      "bio":                "…",
      "status":             0,
      "isVerified":         false,
      "experience":         1250,
      "joinedAt":           "2026-01-02T10:00:00+00:00",
      "confirmedAt":        "2026-01-02T10:05:00+00:00",
      "isConfirmed":        true,
      "bannedUntil":        null,
      "banReason":          null,         // raison INTERNE du ban (back-office only), null hors ban
      "isBanned":           false,        // ban ACTIF dérivé
      "deletedAt":          null,
      "isDeleted":          false,
      "purgedAt":           null,         // estampille d'anonymisation RGPD (irréversible)
      "isPurged":           false,
      "profileCompletedAt": "2026-01-03T09:00:00+00:00",
      "passwordSetAt":      "2026-01-02T10:00:00+00:00",
      "hasPassword":        true,
      "avatarUpdatedAt":    null,
      "hasAvatar":          false,
      "coverUpdatedAt":     null,
      "hasCover":           false,
      "updatedAt":          "2026-06-18T12:00:00+00:00"
    }
  ],
  "nextCursor": { "at": "2026-01-02T10:00:00+00:00", "id": "d26d…" }
  // `null` quand la page courante contient < `limit` items (= dernière page)
}
```

**Exemple curl**

```bash
# Première page (tous les comptes)
curl -s -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  "http://hydrogen.dev.com/admin/users?limit=50"

# Comptes bannis uniquement
curl -s -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  "http://hydrogen.dev.com/admin/users?banned=true"

# Page suivante
curl -s -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  "http://hydrogen.dev.com/admin/users?limit=50&cursorAt=2026-01-02T10:00:00%2B00:00&cursorId=d26d1600cde54bd095e09f8b68ace05f"
```

**Erreurs**

| Status | Body | Sens |
|---|---|---|
| `400` | `{ "error": "Both cursorAt and cursorId must be supplied together." }` | une moitié seulement du curseur a été envoyée |
| `400` | `{ "error": "cursorAt is not a valid datetime." }` | parsing Carbon KO |
| `400` | `{ "error": "cursorId is not a valid hex UUID." }` | hex malformé |
| `403` | `{ "error": "..." }` | auth KO |

---


## `GET /admin/users/banned`

Surface **dédiée** aux comptes dont le **ban est actif** (`banned_until IS NOT NULL AND banned_until > NOW()`, miroir de `User::isBanned()`). Raccourci pratique sur `GET /admin/users` avec le filtre `banned` épinglé à `true` ; le query param `banned` est donc **ignoré** ici. Même pagination keyset, même bornage de `limit` et même enveloppe `{items, nextCursor}` (`AdminUserSerializer`, JSON plat brut).

Les filtres `confirmed`, `verified` et `deleted` restent applicables par-dessus (ex. lister les comptes bannis **et** soft-deleted).

**Query params** : `confirmed`, `verified`, `deleted`, `cursorAt`, `cursorId`, `limit` (`1..100`, défaut `50`). Cf. `GET /admin/users` pour la sémantique. Tri : `joined_at DESC, id DESC`.

**Réponse (200)** — `items[]` strictement identique à `GET /admin/users`.

**Exemple curl**

```bash
# Comptes actuellement bannis
curl -s -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  "http://hydrogen.dev.com/admin/users/banned?limit=50"

# Bannis ET soft-deleted
curl -s -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  "http://hydrogen.dev.com/admin/users/banned?deleted=true"
```

**Erreurs** : identiques à `GET /admin/users` (`400` curseur, `403` auth).

---


## `GET /admin/users/unconfirmed`

Surface **dédiée** aux comptes qui n'ont **jamais confirmé leur e-mail** (`confirmed_at IS NULL`). Raccourci pratique sur `GET /admin/users` avec le filtre `confirmed` épinglé à `false` ; le query param `confirmed` est donc **ignoré** ici. Utile pour relancer ou purger les inscriptions inachevées. Même pagination keyset, même bornage de `limit` et même enveloppe `{items, nextCursor}`.

Les filtres `banned`, `verified` et `deleted` restent applicables par-dessus.

**Query params** : `banned`, `verified`, `deleted`, `cursorAt`, `cursorId`, `limit` (`1..100`, défaut `50`). Cf. `GET /admin/users` pour la sémantique. Tri : `joined_at DESC, id DESC`.

**Réponse (200)** — `items[]` strictement identique à `GET /admin/users`.

**Exemple curl**

```bash
# Comptes jamais confirmés
curl -s -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  "http://hydrogen.dev.com/admin/users/unconfirmed?limit=50"
```

**Erreurs** : identiques à `GET /admin/users` (`400` curseur, `403` auth).

---


## `GET /admin/users/search`

Recherche **full-text** d'un compte via l'index Meilisearch `users` (typo-tolérant), pour l'enquête back-office. **Aucun filtre de visibilité** : la requête Meili ne pose aucun `filter`, donc tout document indexé est éligible. La forme de sortie est **identique** à `GET /admin/users` (`AdminUserSerializer`, JSON plat brut) — Meili ne sert qu'à matcher du texte, on ré-hydrate ensuite les entités depuis **MySQL** (source de vérité).

**⚠️ Limite inhérente à Meili.** Un compte jamais indexé, ou retiré de l'index (ex. anonymisation RGPD qui supprime le document), est **introuvable** ici → utiliser `GET /admin/users` (scan MySQL exhaustif) ou `GET /admin/users/{hex}` (lookup direct) pour ces cas.

**Query params**

| Param    | Valeurs            | Défaut | Notes |
|----------|--------------------|--------|-------|
| `q`      | string             | `""`   | Requête full-text (`username` / `nickname`). Vide = parcours pur. |
| `sort`   | `popular` \| `recent` | _pertinence_ | `popular` → `stats.num_user_follower:desc` ; `recent` → `joined_at:desc` ; sinon ranking Meilisearch. |
| `limit`  | `1..100`           | `50`   | Borné en dur (aligné sur `GET /admin/users`). |
| `offset` | `0+`               | `0`    | Pagination **offset** (≠ keyset de la liste). |

**Réponse (200)** — JSON plat, `items[]` strictement identique à `GET /admin/users` :

```jsonc
{
  "items": [ /* … mêmes champs bruts que items[] de GET /admin/users … */ ],
  "totalHits": 3,        // estimation Meilisearch
  "limit":     50,
  "offset":    0,
  "query":     "alice"
}
```

Les ids présents dans l'index mais absents de MySQL (orphelins) sont silencieusement ignorés ; l'ordre de pertinence Meilisearch est préservé.

**Erreurs**

| Status | Body | Sens |
|---|---|---|
| `503` | `{ "error": "Search backend unavailable.", "detail": "…" }` | Meilisearch injoignable |
| `403` | `{ "error": "..." }` | auth KO |

**Exemple curl**

```bash
# Recherche par username/nickname (token statique ou staff)
curl -s -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  "http://hydrogen.dev.com/admin/users/search?q=alice&limit=20"

# Tri par popularité, page 2
curl -s -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  "http://hydrogen.dev.com/admin/users/search?q=alice&sort=popular&offset=20"
```

**Notes**

- Diffère du public `GET /api/users/search` qui filtre la visibilité (confirmés/actifs only) et renvoie du JSON:API. Ici : aucun filtre + JSON plat admin.
- La recherche par **e-mail** dépend de ce que le pipeline pousse dans l'index `users` (le champ `email` n'est pas garanti searchable) ; pour une recherche e-mail exacte fiable, préférer un scan `GET /admin/users`.

---


## `GET /admin/users/{hex}`

Lecture **360°** d'un compte unique en **JSON:API 1.1** (cf. [Convention de format](#convention-de-format--détails-360-en-jsonapi)) : **strict superset** du public `GET /api/users/{id}` — même enveloppe et même forme d'attributs (via `UserResourceSerializer`, `sex` en slug i18n, dates riches, stats incluses), enrichie de **tous** les champs internes bruts (via `AdminUserSerializer`) fusionnés dans `data.attributes`. Aucun filtrage de visibilité (un compte banni / non confirmé / soft-deleted est lu normalement) ; seul le hash de mot de passe n'est jamais exposé.

Les champs admin-only back-fillés (sans écraser une clé déjà émise par le serializer public) incluent : `email`, `sex` (INT brut), `status`, `bannedUntil`/`isBanned`/`banReason`, `deletedAt`/`isDeleted`, `purgedAt`/`isPurged`, `passwordSetAt`/`hasPassword`, `avatarUpdatedAt`, `coverUpdatedAt`, `confirmedAt`/`isConfirmed`, `profileCompletedAt`, `joinedAt`, `updatedAt` (cf. `items[]` de `GET /admin/users` pour la liste complète).

**Path params**
- `hex` : id de l'utilisateur en 32 hex lowercase.

**Réponses**

| Status | Body | Sens |
|---|---|---|
| `200` | enveloppe `{ jsonapi, data:{ type:"users", id, attributes } }` (`id` = UUID dashé) | trouvé |
| `404` | erreur JSON:API (`errors[].title = "User not found"`) | row absente ou hex malformé |
| `403` | `{ "error": "..." }` | auth KO |

**Exemple curl**

```bash
curl -s -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  -H "Accept: application/vnd.api+json" \
  http://hydrogen.dev.com/admin/users/d26d1600cde54bd095e09f8b68ace05f
```

---


## `GET /admin/users/{hex}/address`

Lecture de l'**adresse postale** d'un compte. Endpoint **dédié** : il ne modifie pas `GET /admin/users/{hex}` (carte d'identité civile). Contrairement au reste de la surface détail admin (JSON:API 360°), il suit la convention **JSON plat brut** (dates ISO-8601 nu), car l'adresse est une simple ligne `user_address` (1:1 avec `user`).

Différence avec le self `GET /api/users/me/address` : ce endpoint expose **en plus** les deux colonnes géo `cityId` (FK `city`, hex 32) et `region`, volontairement masquées côté self (en attente de l'UX d'autocomplete ville). Données brutes : **aucune résolution** du nom de ville.

Forme stable : qu'une ligne existe ou non, les **mêmes clés** sont renvoyées. Si l'utilisateur existe mais n'a jamais renseigné d'adresse, toutes les valeurs sont à `null` (statut `200`, pas `404`).

**Path params**
- `hex` : id de l'utilisateur en 32 hex lowercase.

**Réponses**

| Status | Body | Sens |
|---|---|---|
| `200` | objet plat (cf. ci-dessous) | utilisateur trouvé (adresse présente OU vide à null) |
| `404` | `{ "error": "User not found." }` | compte absent ou hex malformé |
| `403` | `{ "error": "..." }` | auth KO |

Forme du `200` :

```json
{
  "userId":       "d26d1600cde54bd095e09f8b68ace05f",
  "unitNumber":   "4B",
  "streetNumber": "12",
  "addressLine1": "rue des Lilas",
  "addressLine2": null,
  "postalCode":   "75011",
  "cityId":       "0b7c1f2e3a4b5c6d7e8f90a1b2c3d4e5",
  "region":       "IDF",
  "updatedAt":    "2026-06-22T10:00:00+00:00",
  "createdAt":    "2026-06-20T08:30:00+00:00"
}
```

**Exemple curl**

```bash
curl -s -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  http://hydrogen.dev.com/admin/users/d26d1600cde54bd095e09f8b68ace05f/address
```

---


## `PUT /admin/users/{hex}/verified`

Flippe le drapeau `is_verified` d'un utilisateur (style « badge bleu Twitter »). **Réservé admin** : aucune route `/api/*` n'expose ce drapeau en écriture — un utilisateur ne peut pas s'auto-vérifier.

**Body (JSON)**

```json
{ "isVerified": true }
```

`isVerified` est obligatoire, doit être un booléen strict (`true` ou `false`, pas `"true"` ni `1`).

**Comportement par transition**

| Transition | UPDATE `user` | Side-effects |
|---|---|---|
| `none` (déjà à l'état demandé) | non | aucun |
| `verify` (0 → 1) | oui | aucun (pas de notif, pas de XP) |
| `unverify` (1 → 0) | oui | aucun |

Pas de notification produit côté utilisateur — le drapeau pilote uniquement l'icône côté UI, le contexte (preuve d'identité, etc.) est géré hors Hydrogen.

**Path params**
- `hex` : id de l'utilisateur en 32 hex lowercase (`user.id` BINARY(16) → hex).

**Réponses**

| Status | Body | Sens |
|---|---|---|
| `200` | `{ "status": "ok", "userId": "<hex>", "isVerified": true, "transition": "verify" }` | flip 0→1 OK |
| `200` | `{ "status": "ok", "userId": "<hex>", "isVerified": true, "transition": "none" }` | déjà vérifié, no-op idempotent |
| `200` | `{ "status": "ok", "userId": "<hex>", "isVerified": false, "transition": "unverify" }` | drapeau retiré |
| `400` | `{ "error": "Body must be JSON object with 'isVerified' boolean." }` | body mal formé |
| `404` | `{ "error": "User not found." }` | utilisateur absent en DB |
| `403` | `{ "error": "..." }` | auth KO |

**Exemple curl**

```bash
# Vérifier un compte
curl -X PUT \
  -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"isVerified": true}' \
  http://hydrogen.dev.com/admin/users/d26d1600cde54bd095e09f8b68ace05f/verified

# Retirer la vérification
curl -X PUT \
  -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"isVerified": false}' \
  http://hydrogen.dev.com/admin/users/d26d1600cde54bd095e09f8b68ace05f/verified
```

**Notes**

- `isVerified` est exposé en lecture sur **toutes** les ressources `users` (privée et bloc public `author`) — c'est une info publique par construction (un badge se voit).
- Une transition `none` ne touche pas la base — aucun bump `updated_at`, aucun coût.

---


## `POST /admin/users/{hex}/avatar`

Pose / remplace l'**avatar** d'un utilisateur **au nom de l'admin** (modération, support). Réutilise le pipeline self-service (`AvatarUploadService`) : downscale bestfit dans un carré `AVATAR_MAX_DIMENSION` (def 256 px), strip EXIF, ré-encodage WebP, écriture atomique. L'avatar n'est **pas** indexé dans Meili → aucune réindexation.

**Body** — `multipart/form-data`, champ fichier **`avatar`** (obligatoire).

- Formats source acceptés : JPEG / PNG / WEBP / GIF / HEIC / HEIF.
- Taille max : `AVATAR_MAX_UPLOAD_BYTES` (def 256 000 octets).

**Path params**
- `hex` : id de l'utilisateur en 32 hex lowercase (`user.id` BINARY(16) → hex).

**Query params**
- `reason` (optionnel) : motif libre journalisé dans l'audit.

**Réponses (JSON plat)**

| Status | Body | Sens |
|---|---|---|
| `200` | `{ "status":"ok", "userId":"<hex>", "transition":"set", "avatarUpdatedAt":"<ISO8601>", "avatarUrl":"<url>" }` | premier avatar posé |
| `200` | `{ "status":"ok", "userId":"<hex>", "transition":"replace", "avatarUpdatedAt":"<ISO8601>", "avatarUrl":"<url>" }` | avatar existant remplacé |
| `422` | `{ "error":"Form field 'avatar' is required (multipart/form-data).", "code":"avatar.empty" }` | champ fichier manquant |
| `422` | `{ "error":"...", "code":"avatar.invalid_image" }` | image illisible / corrompue |
| `413` | `{ "error":"...", "code":"avatar.too_large" }` | dépasse `AVATAR_MAX_UPLOAD_BYTES` |
| `415` | `{ "error":"...", "code":"avatar.unsupported_format" }` | format hors whitelist |
| `400` | `{ "error":"...", "code":"avatar.upload_failed" }` | erreur transport multipart |
| `500` | `{ "error":"...", "code":"avatar.encoding_failed" \| "avatar.storage_write_failed" }` | échec ré-encodage / écriture disque |
| `404` | `{ "error":"User not found." }` | utilisateur absent |

**Audit** — journalise `user.avatar.set` (`changes: { avatar: { from, to } }`, timestamps ISO) à chaque upload réussi.

**Exemple curl**

```bash
curl -X POST \
  -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  -F "avatar=@/path/to/photo.jpg" \
  "http://hydrogen.dev.com/admin/users/d26d1600cde54bd095e09f8b68ace05f/avatar?reason=support%20cleanup"
```

---


## `DELETE /admin/users/{hex}/avatar`

Retire l'avatar d'un utilisateur **au nom de l'admin** (modération d'un avatar inapproprié). L'utilisateur retombe sur l'avatar par défaut partagé. Idempotent.

**Path params**
- `hex` : id de l'utilisateur en 32 hex lowercase.

**Query params**
- `reason` (optionnel) : motif libre journalisé.

**Réponses (JSON plat)**

| Status | Body | Sens |
|---|---|---|
| `200` | `{ "status":"ok", "userId":"<hex>", "transition":"removed", "avatarUpdatedAt":null, "avatarUrl":"<default url>" }` | avatar supprimé |
| `200` | `{ "status":"ok", "userId":"<hex>", "transition":"none", "avatarUpdatedAt":null, "avatarUrl":"<default url>" }` | déjà sans avatar, no-op |
| `404` | `{ "error":"User not found." }` | utilisateur absent |

**Audit** — journalise `user.avatar.delete` **uniquement** si un avatar existait (`transition:"removed"`). Une suppression no-op (`transition:"none"`) n'écrit aucune entrée.

```bash
curl -X DELETE \
  -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  "http://hydrogen.dev.com/admin/users/d26d1600cde54bd095e09f8b68ace05f/avatar"
```

---


## `POST /admin/users/{hex}/cover`

Pose / remplace la **bannière (cover)** d'un utilisateur **au nom de l'admin**. Pendant de l'avatar : réutilise `CoverUploadService` (downscale bestfit dans `COVER_MAX_WIDTH × COVER_MAX_HEIGHT`, def 1500 × 500, strip EXIF, WebP, écriture atomique). Non indexé Meili.

**Body** — `multipart/form-data`, champ fichier **`cover`** (obligatoire).

- Formats source acceptés : JPEG / PNG / WEBP / GIF / HEIC / HEIF.
- Taille max : `COVER_MAX_UPLOAD_BYTES` (def 600 000 octets).

**Path params**
- `hex` : id de l'utilisateur en 32 hex lowercase.

**Query params**
- `reason` (optionnel) : motif libre journalisé.

**Réponses (JSON plat)**

| Status | Body | Sens |
|---|---|---|
| `200` | `{ "status":"ok", "userId":"<hex>", "transition":"set", "coverUpdatedAt":"<ISO8601>", "coverUrl":"<url>" }` | première cover posée |
| `200` | `{ "status":"ok", "userId":"<hex>", "transition":"replace", "coverUpdatedAt":"<ISO8601>", "coverUrl":"<url>" }` | cover existante remplacée |
| `422` | `{ "error":"Form field 'cover' is required (multipart/form-data).", "code":"cover.empty" }` | champ fichier manquant |
| `422` | `{ "error":"...", "code":"cover.invalid_image" }` | image illisible / corrompue |
| `413` | `{ "error":"...", "code":"cover.too_large" }` | dépasse `COVER_MAX_UPLOAD_BYTES` |
| `415` | `{ "error":"...", "code":"cover.unsupported_format" }` | format hors whitelist |
| `400` | `{ "error":"...", "code":"cover.upload_failed" }` | erreur transport multipart |
| `500` | `{ "error":"...", "code":"cover.encoding_failed" \| "cover.storage_write_failed" }` | échec ré-encodage / écriture disque |
| `404` | `{ "error":"User not found." }` | utilisateur absent |

**Audit** — journalise `user.cover.set` à chaque upload réussi.

```bash
curl -X POST \
  -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  -F "cover=@/path/to/banner.jpg" \
  "http://hydrogen.dev.com/admin/users/d26d1600cde54bd095e09f8b68ace05f/cover"
```

---


## `DELETE /admin/users/{hex}/cover`

Retire la cover d'un utilisateur **au nom de l'admin**. Retombe sur la cover par défaut partagée. Idempotent.

**Path params**
- `hex` : id de l'utilisateur en 32 hex lowercase.

**Query params**
- `reason` (optionnel) : motif libre journalisé.

**Réponses (JSON plat)**

| Status | Body | Sens |
|---|---|---|
| `200` | `{ "status":"ok", "userId":"<hex>", "transition":"removed", "coverUpdatedAt":null, "coverUrl":"<default url>" }` | cover supprimée |
| `200` | `{ "status":"ok", "userId":"<hex>", "transition":"none", "coverUpdatedAt":null, "coverUrl":"<default url>" }` | déjà sans cover, no-op |
| `404` | `{ "error":"User not found." }` | utilisateur absent |

**Audit** — journalise `user.cover.delete` **uniquement** si une cover existait (`transition:"removed"`).

```bash
curl -X DELETE \
  -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  "http://hydrogen.dev.com/admin/users/d26d1600cde54bd095e09f8b68ace05f/cover"
```

---


## `PUT /admin/users/{hex}/ban`

Bannit un utilisateur. Le modèle est **temporel** : la colonne `user.banned_until` porte la date de fin de ban ; l'utilisateur est banni tant que ce timestamp est dans le futur (`banned_until > NOW()`). **Réservé admin** : aucune route `/api/*` n'expose ce drapeau en écriture.

**Body (JSON, tous les champs optionnels)**

```json
{ "until": "2026-12-31T00:00:00+00:00", "reason": "spam massif" }
```

- `until` = date ISO-8601 de levée du ban → **bannissement temporaire** (doit être strictement dans le futur).
- `until` à `null`, **absent**, ou body vide → **bannissement permanent** (sentinelle `9999-12-31T23:59:59`).
- `reason` = **raison interne** (note libre, ≤ 500 caractères) persistée sur `user.ban_reason`. Visible **back-office uniquement** (`AdminUserSerializer` → `GET /admin/users` et `GET /admin/users/{hex}`), jamais renvoyée par les serializers publics, jamais indexée dans Meilisearch, jamais exposée à l'utilisateur banni. Trim + `""` → `null`. Elle est aussi journalisée dans `user_action_log` (champ `reason`). Effacée automatiquement à l'unban.

**Side-effects**

- Un `UPDATE user` (`banned_until` + `ban_reason` + bump `updated_at`).
- **Toutes les sessions actives de l'utilisateur sont révoquées** (`DELETE FROM user_session`), pour que le ban prenne effet immédiatement : le chemin d'authentification par token ne re-vérifie pas `isBanned()` à chaque requête, seul le login le contrôle. Le nombre de sessions supprimées est renvoyé dans `sessionsRevoked`.
- Un **reindex Meili best-effort** de l'utilisateur (`banned_until` est un champ indexé). Best-effort : un incident d'index n'échoue jamais le ban.
- Aucune notif, aucun XP.

**Comportement par transition**

| Transition | Sens | Sessions révoquées |
|---|---|---|
| `ban` | l'utilisateur n'était pas banni (aucun ban actif) | oui |
| `update` | l'utilisateur était déjà banni — la fenêtre est prolongée/raccourcie | oui (souvent 0, plus de session valide) |

**Path params**
- `hex` : id de l'utilisateur en 32 hex lowercase.

**Réponses**

| Status | Body | Sens |
|---|---|---|
| `200` | `{ "status": "ok", "userId": "<hex>", "isBanned": true, "bannedUntil": "<ISO8601>", "banReason": "spam massif", "sessionsRevoked": 3, "transition": "ban" }` | ban posé |
| `200` | `{ ..., "transition": "update" }` | ban déjà actif, fenêtre mise à jour |
| `400` | `{ "error": "Body 'until' must be an ISO-8601 datetime string or null." }` | `until` mal typé / JSON invalide |
| `422` | `{ "error": "Ban expiry 'until' must be in the future." }` | `until` dans le passé |
| `422` | `{ "error": "Ban 'reason' must be a string of at most 500 characters." }` | `reason` non-string ou > 500 caractères |
| `404` | `{ "error": "User not found." }` | utilisateur absent en DB |
| `403` | `{ "error": "..." }` | auth KO |

**Exemple curl**

```bash
# Ban permanent
curl -X PUT \
  -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}' \
  http://hydrogen.dev.com/admin/users/d26d1600cde54bd095e09f8b68ace05f/ban

# Ban temporaire (7 jours, exemple) avec raison interne
curl -X PUT \
  -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"until": "2026-06-26T00:00:00+00:00", "reason": "spam massif"}' \
  http://hydrogen.dev.com/admin/users/d26d1600cde54bd095e09f8b68ace05f/ban
```

**Notes**

- `bannedUntil` et `isBanned` sont exposés en lecture sur les ressources `users` ; sur un login refusé pour ban, l'API renvoie `meta.bannedUntil` pour que le front affiche la date de levée. `banReason` reste **interne** : jamais sur les ressources publiques.
- Un compte banni renvoie `404` sur sa page profil publique web `/@username`.

---


## `PUT /admin/users/{hex}/unban`

Lève le ban d'un utilisateur en remettant `user.banned_until` à `NULL` **et** en effaçant la raison interne `user.ban_reason`. **Réservé admin**. Pas de body.

**Side-effects** : un seul `UPDATE user` (`banned_until` + `ban_reason` remis à `NULL`, + bump `updated_at`) sur transition `unban`, suivi d'un **reindex Meili best-effort** (`banned_until` est indexé). Aucune session n'est restaurée — l'utilisateur devra se reconnecter (ses sessions ont été révoquées lors du ban). Aucune notif, aucun XP. Une transition `none` ne touche ni la base ni l'index.

**Comportement par transition**

| Transition | UPDATE `user` | Sens |
|---|---|---|
| `unban` | oui | une empreinte de ban existait (`banned_until` non `NULL`, active **ou** expirée) → effacée |
| `none` | non | aucune empreinte de ban (`banned_until` déjà `NULL`) → no-op idempotent |

**Réponses**

| Status | Body | Sens |
|---|---|---|
| `200` | `{ "status": "ok", "userId": "<hex>", "isBanned": false, "transition": "unban" }` | ban levé |
| `200` | `{ "status": "ok", "userId": "<hex>", "isBanned": false, "transition": "none" }` | rien à lever, no-op |
| `404` | `{ "error": "User not found." }` | utilisateur absent en DB |
| `403` | `{ "error": "..." }` | auth KO |

**Exemple curl**

```bash
curl -X PUT \
  -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  http://hydrogen.dev.com/admin/users/d26d1600cde54bd095e09f8b68ace05f/unban
```

---


## `POST /admin/users/{hex}/anonymize`

Déclenche **immédiatement** l'anonymisation RGPD d'un compte (« droit à l'effacement »), côté support — **sans attendre la période de grâce**. Réutilise le cœur par-utilisateur du pipeline de purge déjà exécuté en cron (`bin/account-purge.php`).

**⚠️ Irréversible.** Une fois `purged_at` posé, le compte ne peut plus être réactivé (contrairement au soft-delete self-service, réversible par re-login pendant la grâce). Pas de body.

**Side-effects (dans l'ordre)**

1. Révocation de **toutes les sessions actives** (le compte peut encore être vivant — l'anonymisation est directe, hors grâce).
2. Effacement de **tous les médias** de l'utilisateur : fichiers disque + lignes DB + tables annexes + index Meilisearch (un par un via `MediaDeleteService`). Le compteur est renvoyé dans `mediasErased`.
3. Scrub des colonnes PII du `user` (`email`/`username` remplacés par des sentinelles dérivées de l'id, `nickname`/`name`/`firstname`/`bio`/`sex`/`birthdate`/… mis à `NULL`) + estampille `purged_at`.
4. Retrait du document utilisateur de l'index de découverte.
5. Suppression de tout token de suppression résiduel.

**Comportement par transition**

| Transition | Sens | Écritures |
|---|---|---|
| `anonymize` | le compte n'était pas encore purgé → effacé/anonymisé | sessions + médias + scrub user + Meili + tokens |
| `none` | compte déjà anonymisé (`purged_at` non nul) → no-op idempotent | aucune |

**Path params**
- `hex` : id de l'utilisateur en 32 hex lowercase.

**Réponses**

| Status | Body | Sens |
|---|---|---|
| `200` | `{ "status": "ok", "userId": "<hex>", "mediasErased": 12, "transition": "anonymize" }` | anonymisation effectuée |
| `200` | `{ "status": "ok", "userId": "<hex>", "mediasErased": 0, "transition": "none" }` | déjà anonymisé, no-op |
| `404` | `{ "error": "User not found." }` | row absente ou hex malformé |
| `403` | `{ "error": "..." }` | auth KO |

**Exemple curl**

```bash
curl -X POST \
  -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  http://hydrogen.dev.com/admin/users/d26d1600cde54bd095e09f8b68ace05f/anonymize
```

**Notes**

- Diffère du soft-delete self-service (`/api/...` côté utilisateur) qui passe par une **période de grâce** réversible puis une purge cron. Cet endpoint **court-circuite la grâce** : à n'utiliser que sur instruction explicite (demande RGPD vérifiée).
- L'état d'anonymisation est lisible via `purgedAt` / `isPurged` sur `GET /admin/users` et `GET /admin/users/{hex}`.

---


## `PUT /admin/users/{hex}/profile`

Éditeur d'**identité** back-office : corrige les colonnes de profil d'un compte sous l'autorité d'un opérateur. **Réservé admin** : l'auto-édition utilisateur côté `/api/*` passe par d'autres parcours soumis à quarantaine/cooldown — pas par cet endpoint.

**Body** (objet JSON ; **tous les champs optionnels**, seuls les champs présents sont touchés) :

| Champ | Type | Validation |
|---|---|---|
| `username`  | string | normalisé (lowercase+trim), policy (3..32, `[a-z0-9._-]`, bornes, blocklist), **unique**. Appliqué via `changeUsername()` (history préservé) **sans** quarantaine/cooldown self-service. |
| `nickname`  | string \| `null` | normalisé (collapse espaces), policy (1..32, pas de caractères de contrôle). `null` (ou vide) = efface (fallback username). |
| `name`      | string \| `null` | texte libre ; vide ⇒ `null`. |
| `firstname` | string \| `null` | texte libre ; vide ⇒ `null`. |
| `email`     | string | format RFC + ≤ 255 chars, **unique**. |
| `bio`       | string \| `null` | texte libre ; vide ⇒ `null`. |
| `reason`    | string | raison libre journalisée (optionnel). |

**Comportement par transition**

| Transition | Sens | Écritures |
|---|---|---|
| `update` | au moins un champ change effectivement | 1..2 `UPDATE user` + 1 ligne d'audit (`changed` = liste des champs) + 1 reindex Meili best-effort (champs indexés touchés : `username`/`nickname`/`email`/`bio`) |
| `none`   | aucun changement effectif (valeurs identiques) | aucune (ni DB, ni audit, ni reindex) |

**Path params**
- `hex` : id de l'utilisateur en 32 hex lowercase.

**Réponses**

| Status | Body | Sens |
|---|---|---|
| `200` | `{ "status": "ok", "userId": "<hex>", "transition": "update", "changed": ["username","email"] }` | appliqué |
| `200` | `{ "status": "ok", "userId": "<hex>", "transition": "none", "changed": [] }` | no-op idempotent |
| `400` | `{ "error": "Body must be a JSON object." }` | JSON KO |
| `404` | `{ "error": "User not found." }` | row absente ou hex malformé |
| `409` | `{ "error": "Conflict.", "fields": { "username": ["username.taken"] } }` | `username`/`email` déjà pris |
| `422` | `{ "error": "Validation failed.", "fields": { "<champ>": ["<code>"] } }` | policy/format KO |
| `403` | `{ "error": "..." }` | auth KO |

**Exemple curl**

```bash
# Corriger l'e-mail + le nom d'affichage (acteur staff nominatif)
curl -X PUT \
  -H "Authorization: Bearer $STAFF_BEARER_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"email":"new@example.com","nickname":"Alice B.","reason":"demande support #4213"}' \
  http://hydrogen.dev.com/admin/users/d26d1600cde54bd095e09f8b68ace05f/profile
```

**Notes**

- `username` et `nickname` ne passent **pas** par `updateProfileFields()` : ils ont leurs chemins policy-aware dédiés (`changeUsername()` transactionnel avec `username_history`, `updateNickname()`).
- L'autorité admin **bypasse** volontairement la quarantaine/cooldown du parcours self-service de changement de username (correction/modération immédiate).

---


## `GET /admin/users/{hex}/history`

Trace nominative de **toutes les actions effectuées SUR ce compte** (ban/unban/verified/anonymize/profile/avatar/cover), du plus récent au plus ancien, depuis `hxa_bo.user_action_log`. Pagination keyset par `id` décroissant.

**Query params**

| Param | Valeurs | Défaut | Notes |
|---|---|---|---|
| `limit` | `1..100` | `50` | borné en dur. |
| `before` | entier positif | _aucun_ | `id` de la dernière ligne de la page précédente (keyset). |

**Réponse (200)**

```jsonc
{
  "items": [
    {
      "id":        4821,
      "action":    "user.ban",            // user.ban | user.unban | user.verified.set | user.anonymize | user.profile.update | user.avatar.set | user.avatar.delete | user.cover.set | user.cover.delete
      "staff":     { "id": 7, "username": "moderator_jo" },  // null = action via token statique (« système »)
      "changes":   { "bannedUntil": { "from": null, "to": "9999-12-31T23:59:59+00:00" }, "transition": "ban" },
      "reason":    "spam répété",          // string libre ou null
      "ip":        "203.0.113.7",          // IP lisible (inet_ntop) ou null
      "createdAt": "2026-06-22T14:30:00+00:00"
    }
  ],
  "nextCursor": 4810
  // `null` quand la page courante contient < `limit` items (= dernière page)
}
```

**Path params**
- `hex` : id de l'utilisateur en 32 hex lowercase.

**Réponses**

| Status | Body | Sens |
|---|---|---|
| `200` | enveloppe `{ items, nextCursor }` | OK |
| `400` | `{ "error": "before must be a positive integer." }` | curseur malformé |
| `404` | `{ "error": "User not found." }` | row absente ou hex malformé |
| `403` | `{ "error": "..." }` | auth KO |

**Exemple curl**

```bash
# Première page
curl -s -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  "http://hydrogen.dev.com/admin/users/d26d1600cde54bd095e09f8b68ace05f/history?limit=50"

# Page suivante
curl -s -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  "http://hydrogen.dev.com/admin/users/d26d1600cde54bd095e09f8b68ace05f/history?before=4810"
```

**Notes**

- `changes` est le **diff effectif** propre à chaque action (forme `{ champ: { from, to } }`, plus quelques métadonnées comme `transition` ou `mediasErased`). Il reflète ce qui a réellement changé, pas le body brut reçu.
- `staff: null` signale une action exécutée avec le **token statique** (Talend/système) ; un objet `{ id, username }` identifie l'opérateur nominatif.

---


## `GET /admin/users/{hex}/comments`

Investigation d'abus : **tous les commentaires écrits par ce compte**, **tous
médias confondus**, **soft-deleted inclus** (`body` brut). Newest-first, keyset
sur `(created_at DESC, id DESC)` — adossé à `idx_mc_user_created`.

Renvoie l'objet commentaire plat décrit dans
[Modération des commentaires](#modération-des-commentaires).

**Query** (tous optionnels)

| Param | Défaut | Sens |
|---|---|---|
| `cursorAt` | — | ISO-8601, `created_at` de la dernière ligne de la page |
| `cursorId` | — | hex 32, id de cette même ligne (tiebreaker) |
| `limit` | `50` | borné `1..100` |

Les deux moitiés du curseur vont **ensemble** ; une seule ⇒ `400`.

**Réponse (200)** : enveloppe `{ items: [<comment plat>, …], nextCursor }`
(même forme que `GET /admin/media/{hex}/comments`).

```bash
# Première page
curl -s -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  "http://hydrogen.dev.com/admin/users/d26d1600cde54bd095e09f8b68ace05f/comments?limit=50"
# Page suivante
curl -s -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  "http://hydrogen.dev.com/admin/users/d26d1600cde54bd095e09f8b68ace05f/comments?cursorAt=2026-06-20T14:03:00%2B00:00&cursorId=0a1b..."
```

**Erreurs**

| Status | Body | Sens |
|---|---|---|
| `400` | `{ "error": "Both cursorAt and cursorId must be supplied together." }` | curseur partiel |
| `400` | `{ "error": "cursorAt is not a valid datetime." }` | `cursorAt` illisible |
| `400` | `{ "error": "cursorId is not a valid hex UUID." }` | `cursorId` malformé |
| `404` | `{ "error": "User not found." }` | hex de compte malformé |
| `403` | `{ "error": "..." }` | auth KO |

> Note : rattaché à la surface `/admin/users/*` → auth **HYBRIDE** (token
> statique OU staff nominatif), contrairement aux endpoints
> `/admin/comments/*` et `/admin/media/{hex}/comments` (token statique seul).
> Un compte sans commentaire renvoie `items: []` (pas `404`).

---


## `POST /admin/users/{hex}/reindex`

Re-pousse un utilisateur unique dans l'index Meilisearch `users`, en relisant la DB (`user` + snapshot de compteurs `user_stats`) via [UserIndexService::reindex()](../src/Domain/User/UserIndexService.php). Pendant de `POST /admin/media/{hex}/reindex`, pour réparer une dérive de l'index `users`.

**Note — réindexation automatique inline.** Depuis 2026-06-22, toute mutation d'un champ **indexé** de l'utilisateur déclenche un reindex `UserIndexService::reindex()` **best-effort** (jamais bloquant) à l'endroit de la mutation : changement de `username` ([UsernameChangeService](../src/Domain/User/UsernameChangeService.php) + `PUT /admin/users/{hex}/profile`), de `nickname` (`PUT /api/users/me/nickname` + profil admin), de `email`/`bio` (profil admin), `banned_until` (`PUT /admin/users/{hex}/ban` & `/unban`), `confirmed_at` (confirmation e-mail + reset de mot de passe qui confirme), et `experience`/XP (upload média, paliers de badge, coupon). Cet endpoint reste utile pour **réparer** une dérive (incident Meili au moment de la mutation, ou recoller un enrichissement Talend). Les champs **non indexés** (`name`, `firstname`, `is_verified`) ne déclenchent volontairement aucun reindex.

**⚠️ Merge partiel, pas un remplacement.** Le push utilise `updateDocuments` (MERGE) : seul le **cœur appartenant à MySQL** est réécrit (`username`, `nickname`, `email`, `sex`, `birthdate`, `bio`, `experience`, `status`, `user_type`, `confirmed_at`, `joined_at`, `banned_until`, `profile_complited_at`, `stats`). Les champs **enrichis par Talend** que MySQL ne sait pas reconstruire fidèlement (`birthplace_city.name`, `nationality`, `settings`, `focus`, `badges`, `sponsopship`) sont **laissés intacts** sur le document existant. Un document neuf reçoit donc uniquement le cœur ; les enrichissements seront recollés par le pipeline IA.

Le shape envoyé respecte l'index à l'identique, y compris ses bizarreries : dates en **timestamps UNIX** (pas ISO), `email` imbriqué `{value, domain}`, et la clé mal orthographiée `profile_complited_at` conservée telle quelle.

**Path params**
- `hex` : id de l'utilisateur en 32 hex (format `user.id` BINARY(16) → hex lowercase).

**Réponses**

| Status | Body | Sens |
|---|---|---|
| `200` | `{ "status": "reindexed", "userId": "<hex>" }` | document Meili mis à jour (merge) |
| `200` | `{ "status": "removed",   "userId": "<hex>" }` | user absent en DB **ou purgé** → le doc Meili stale est purgé |
| `400` | `{ "error": "Invalid user id." }` | hex mal formé |
| `403` | `{ "error": "..." }` | auth KO |

Un compte anonymisé (`purged_at` non nul) est traité comme absent : `removed` (le tombstone ne doit jamais ressortir dans `/users/search`).

**Exemple curl**

```bash
curl -X POST \
  -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  "http://hydrogen.dev.com/admin/users/d26d1600cde54bd095e09f8b68ace05f/reindex"
```

---


## `POST /admin/users/reindex-all`

Backfill complet de l'index Meili `users` par **lots keyset-paginés**. Chaque appel traite UN batch et renvoie le curseur du suivant. Le client (Talend / Postman) boucle jusqu'à `done = true`. Symétrique de `POST /admin/media/reindex-all`.

Pagination par clé primaire BINARY(16) ASC : pas de drift offset, robuste aux insertions concurrentes. Chaque ligne est poussée en merge partiel (mêmes règles que `POST /admin/users/{hex}/reindex`).

**Query params**

| Param | Type | Défaut | Min | Max |
|---|---|---|---|---|
| `cursor` | hex (32 chars) | `null` (début) | — | — |
| `batchSize` | int | `200` | `1` | `1000` |

`cursor` exclu : passer l'id du dernier utilisateur traité par l'appel précédent. Vide ou absent ⇒ on part du début.

**Réponse (200)**

```json
{
  "processed":  199,
  "removed":    1,
  "failed": [
    { "userId": "a1b2…", "error": "Meilisearch: connection refused" }
  ],
  "lastId":     "f0e1d2c3b4a5969788798a8b8c8d8e8f",
  "nextCursor": "f0e1d2c3b4a5969788798a8b8c8d8e8f",
  "done":       false,
  "totalAll":   8_421,
  "durationMs": 2987
}
```

| Champ | Sens |
|---|---|
| `processed` | utilisateurs indexés avec succès dans ce batch |
| `removed`   | rows absents en DB **ou purgés** dont le doc Meili stale a été purgé |
| `failed`    | liste des erreurs par-utilisateur — **n'interrompt pas le batch** |
| `lastId`    | dernier id parcouru dans le batch (`null` si batch vide) |
| `nextCursor`| à passer en `?cursor=` au prochain appel ; `null` quand `done=true` |
| `done`      | `true` quand le batch a renvoyé moins de rows que demandé → fin du backfill |
| `totalAll`  | `COUNT(*) user` au moment de l'appel — pour reporter une progression côté caller |
| `durationMs`| latence serveur du batch |

**Erreurs**

| Status | Body |
|---|---|
| `400` | `{ "error": "Invalid cursor." }` |
| `400` | `{ "error": "Invalid batchSize." }` |
| `403` | `{ "error": "..." }` |

**Pattern d'utilisation (Talend / curl boucle)**

```bash
cursor=""
while : ; do
  resp=$(curl -s -X POST \
    -H "Authorization: Bearer $ADMIN_API_TOKEN" \
    "http://hydrogen.dev.com/admin/users/reindex-all?batchSize=500&cursor=$cursor")
  echo "$resp" | jq '{processed, removed, done, durationMs}'

  done=$(echo "$resp" | jq -r '.done')
  cursor=$(echo "$resp" | jq -r '.nextCursor // empty')

  [ "$done" = "true" ] && break
done
```

---


## Back-office staff (`/admin/staff/*`)

Surface **distincte** du reste de `/admin/*`. Là où les endpoints ci-dessus visent des appels service-to-service (Talend, Postman) authentifiés par un **token statique** unique (`ADMIN_API_TOKEN`), le back-office vise des **opérateurs humains** : chacun se connecte avec identifiant + mot de passe et reçoit un **token bearer nominatif** (révocable, à durée limitée). Les deux surfaces coexistent et n'utilisent pas le même token.

## Niveaux de staff

Trois rôles, ordonnés par privilège (un `≥` numérique exprime « au moins ce niveau ») :

| Rôle | slug | Accès |
|---|---|---|
| Consultant | `consultant` | lecture seule de certaines stats |
| Modérateur | `moderator` | + actions de modération (accès limité) |
| Admin | `admin` | **accès absolu**, dont la gestion du staff |

Stockage : colonne `staff.role` (TINYINT : `1`=consultant, `2`=moderator, `3`=admin). L'API ne parle que de slugs. Les comptes pré-existants à la migration sont promus **admin** (fondateurs) ; tout nouveau compte est **consultant** par défaut (moindre privilège).

## Authentification

```bash
# 1. Login → récupérer le token
curl -s -X POST https://host/admin/staff/login \
  -H 'Content-Type: application/json' \
  -d '{"username":"lbenin","password":"********"}'
# ⇒ { "token": "…", "expiresAt": "2026-…", "staff": { … } }

# 2. Porter le token sur les appels suivants
curl -s https://host/admin/staff/me -H "Authorization: Bearer <token>"
```

Durée de session : `STAFF_TOKEN_LIFETIME_HOURS` (défaut **12 h**), glissante (prolongée à chaque requête authentifiée). Seul le **hash SHA-256** du token est stocké (`hxa_bo.staff_token`) — le token en clair n'est montré qu'une fois, au login. Un échec d'auth renvoie `401` (et non `403`) : c'est une surface de login humaine, le client doit se ré-authentifier. Toutes les raisons d'échec sont confondues (pas d'oracle).

## Endpoints

| Méthode | Chemin | Rôle requis | Description |
|---|---|---|---|
| `POST` | `/admin/staff/login` | — (public) | Échange identifiants → token bearer |
| `POST` | `/admin/staff/logout` | tout staff | Révoque la session courante (204) |
| `GET` | `/admin/staff/me` | tout staff | Profil de l'opérateur courant (dont son rôle) |
| `GET` | `/admin/staff` | admin | Liste des opérateurs (keyset `?after`/`?limit`) |
| `POST` | `/admin/staff` | admin | Crée un opérateur (201) |
| `GET` | `/admin/staff/{id}` | admin | Détail d'un opérateur |
| `PUT` | `/admin/staff/{id}` | admin | Met à jour (partiel ; `password` optionnel) |
| `DELETE` | `/admin/staff/{id}` | admin | Supprime (204) |

**Corps de `POST /admin/staff`** :

```json
{
  "username":  "jdupont",
  "email":     "j.dupont@example.com",
  "password":  "Sup3r!Secret",
  "role":      "moderator",
  "name":      "DUPONT",
  "firstname": "Jean",
  "isActive":  true
}
```

`username` (3-20, `[A-Za-z0-9._-]`), `email`, `password` (politique mot de passe : ≥ 12 caractères, casses + chiffre + spécial) et `role` sont requis ; `name`/`firstname`/`isActive` optionnels. `PUT` accepte les mêmes champs, tous optionnels (seuls les champs présents changent ; `password` non vide effectue une rotation).

**Format de `GET /admin/staff/{id}`** : à la différence des listes/mutations staff (JSON plat), le **détail** d'un opérateur suit la [convention JSON:API 1.1](#convention-de-format--détails-360-en-jsonapi) : enveloppe `{ jsonapi, data:{ type:"staff", id, attributes } }` (`id` = entier `staff.id`, remonté au niveau `data.id` ; tous les champs `StaffSerializer` sauf `id` dans `attributes` ; `role` en slug ; hash jamais sérialisé). `404` en erreur JSON:API.

**Garde anti-lockout** : impossible de **rétrograder, désactiver ou supprimer le dernier admin actif** (`422`) — il reste toujours au moins un opérateur capable de gérer le staff.

Erreurs : `400` corps malformé / champ requis manquant ; `401` non authentifié ; `403` rôle insuffisant ; `404` id inconnu ; `422` règle métier (unicité username/email, mot de passe faible, rôle inconnu, lockout). Le hash du mot de passe n'est **jamais** sérialisé.

## Journal des actions staff

« Toutes les actions du staff sont loguées. » Chaque requête **mutante** sur `/admin/staff/*` est tracée dans `hxa_bo.staff_action_log` par [StaffActionLogMiddleware](../src/Http/Middleware/StaffActionLogMiddleware.php) (monté au niveau du groupe — aucune action à modifier). Le login enregistre en plus ses propres événements `staff.login` / `staff.login.failed` (avec l'identifiant tenté, pour voir le brute-force). Contrairement à l'audit `/admin/*` (SQLite, empreinte de token anonyme), ce journal est **nominatif** : il référence le `staff_id` et dénormalise le `username` (la trace survit à la suppression du compte). Table distincte du `staff_log` historique (un diff d'état du compte staff lui-même).

| Colonne | Type | Description |
|---|---|---|
| `id` | BIGINT PK | auto-incrément |
| `staff_id` | INT | acteur (`null` = tentative non authentifiée, ex. login raté) |
| `username` | VARCHAR | identifiant dénormalisé de l'acteur |
| `action` | VARCHAR | label logique (`staff.login`, `staff.request`, …) |
| `method` / `path` / `status` | | requête HTTP + statut final |
| `metadata` | JSON | contexte optionnel |
| `ip_address` / `user_agent` | | provenance |
| `created_at` | DATETIME | |

> **Migration à jouer** : `database/migrations/2026_06_19_160000_refactor_staff_for_back_office.sql` (ajoute `staff.role`/timestamps, crée `staff_token` + `staff_action_log`). À appliquer avant utilisation.

---
