Utilisateurs
Surface /admin/users/* — authentification HYBRIDE & traçabilité
Section titled “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 restentNULL. - 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
Section titled “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)
{ "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
# Première page (tous les comptes)curl -s -H "Authorization: Bearer $ADMIN_API_TOKEN" \ "http://hydrogen.dev.com/admin/users?limit=50"
# Comptes bannis uniquementcurl -s -H "Authorization: Bearer $ADMIN_API_TOKEN" \ "http://hydrogen.dev.com/admin/users?banned=true"
# Page suivantecurl -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
Section titled “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
# Comptes actuellement banniscurl -s -H "Authorization: Bearer $ADMIN_API_TOKEN" \ "http://hydrogen.dev.com/admin/users/banned?limit=50"
# Bannis ET soft-deletedcurl -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
Section titled “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
# Comptes jamais confirméscurl -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
Section titled “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 :
{ "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
# 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 2curl -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/searchqui 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 champemailn’est pas garanti searchable) ; pour une recherche e-mail exacte fiable, préférer un scanGET /admin/users.
GET /admin/users/{hex}
Section titled “GET /admin/users/{hex}”Lecture 360° d’un compte unique en JSON:API 1.1 (cf. Convention de format) : 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
curl -s -H "Authorization: Bearer $ADMIN_API_TOKEN" \ -H "Accept: application/vnd.api+json" \ http://hydrogen.dev.com/admin/users/d26d1600cde54bd095e09f8b68ace05fGET /admin/users/{hex}/address
Section titled “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 :
{ "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
curl -s -H "Authorization: Bearer $ADMIN_API_TOKEN" \ http://hydrogen.dev.com/admin/users/d26d1600cde54bd095e09f8b68ace05f/addressPUT /admin/users/{hex}/verified
Section titled “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)
{ "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.idBINARY(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
# Vérifier un comptecurl -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érificationcurl -X PUT \ -H "Authorization: Bearer $ADMIN_API_TOKEN" \ -H "Content-Type: application/json" \ -d '{"isVerified": false}' \ http://hydrogen.dev.com/admin/users/d26d1600cde54bd095e09f8b68ace05f/verifiedNotes
isVerifiedest exposé en lecture sur toutes les ressourcesusers(privée et bloc publicauthor) — c’est une info publique par construction (un badge se voit).- Une transition
nonene touche pas la base — aucun bumpupdated_at, aucun coût.
POST /admin/users/{hex}/avatar
Section titled “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.idBINARY(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
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
Section titled “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.
curl -X DELETE \ -H "Authorization: Bearer $ADMIN_API_TOKEN" \ "http://hydrogen.dev.com/admin/users/d26d1600cde54bd095e09f8b68ace05f/avatar"POST /admin/users/{hex}/cover
Section titled “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.
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
Section titled “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").
curl -X DELETE \ -H "Authorization: Bearer $ADMIN_API_TOKEN" \ "http://hydrogen.dev.com/admin/users/d26d1600cde54bd095e09f8b68ace05f/cover"PUT /admin/users/{hex}/ban
Section titled “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)
{ "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 (sentinelle9999-12-31T23:59:59).reason= raison interne (note libre, ≤ 500 caractères) persistée suruser.ban_reason. Visible back-office uniquement (AdminUserSerializer→GET /admin/usersetGET /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 dansuser_action_log(champreason). Effacée automatiquement à l’unban.
Side-effects
- Un
UPDATE user(banned_until+ban_reason+ bumpupdated_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 pasisBanned()à chaque requête, seul le login le contrôle. Le nombre de sessions supprimées est renvoyé danssessionsRevoked. - Un reindex Meili best-effort de l’utilisateur (
banned_untilest 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
# Ban permanentcurl -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 internecurl -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/banNotes
bannedUntiletisBannedsont exposés en lecture sur les ressourcesusers; sur un login refusé pour ban, l’API renvoiemeta.bannedUntilpour que le front affiche la date de levée.banReasonreste interne : jamais sur les ressources publiques.- Un compte banni renvoie
404sur sa page profil publique web/@username.
PUT /admin/users/{hex}/unban
Section titled “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
curl -X PUT \ -H "Authorization: Bearer $ADMIN_API_TOKEN" \ http://hydrogen.dev.com/admin/users/d26d1600cde54bd095e09f8b68ace05f/unbanPOST /admin/users/{hex}/anonymize
Section titled “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)
- Révocation de toutes les sessions actives (le compte peut encore être vivant — l’anonymisation est directe, hors grâce).
- 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é dansmediasErased. - Scrub des colonnes PII du
user(email/usernameremplacés par des sentinelles dérivées de l’id,nickname/name/firstname/bio/sex/birthdate/… mis àNULL) + estampillepurged_at. - Retrait du document utilisateur de l’index de découverte.
- 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
curl -X POST \ -H "Authorization: Bearer $ADMIN_API_TOKEN" \ http://hydrogen.dev.com/admin/users/d26d1600cde54bd095e09f8b68ace05f/anonymizeNotes
- 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/isPurgedsurGET /admin/usersetGET /admin/users/{hex}.
PUT /admin/users/{hex}/profile
Section titled “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
# 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/profileNotes
usernameetnicknamene passent pas parupdateProfileFields(): ils ont leurs chemins policy-aware dédiés (changeUsername()transactionnel avecusername_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
Section titled “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)
{ "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
# Première pagecurl -s -H "Authorization: Bearer $ADMIN_API_TOKEN" \ "http://hydrogen.dev.com/admin/users/d26d1600cde54bd095e09f8b68ace05f/history?limit=50"
# Page suivantecurl -s -H "Authorization: Bearer $ADMIN_API_TOKEN" \ "http://hydrogen.dev.com/admin/users/d26d1600cde54bd095e09f8b68ace05f/history?before=4810"Notes
changesest le diff effectif propre à chaque action (forme{ champ: { from, to } }, plus quelques métadonnées commetransitionoumediasErased). Il reflète ce qui a réellement changé, pas le body brut reçu.staff: nullsignale une action exécutée avec le token statique (Talend/système) ; un objet{ id, username }identifie l’opérateur nominatif.
POST /admin/users/{hex}/reindex
Section titled “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(). 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 + 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 (formatuser.idBINARY(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
curl -X POST \ -H "Authorization: Bearer $ADMIN_API_TOKEN" \ "http://hydrogen.dev.com/admin/users/d26d1600cde54bd095e09f8b68ace05f/reindex"POST /admin/users/reindex-all
Section titled “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)
{ "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)
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" ] && breakdoneBack-office staff (/admin/staff/*)
Section titled “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
Section titled “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
Section titled “Authentification”# 1. Login → récupérer le tokencurl -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 suivantscurl -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
Section titled “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 :
{ "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 : 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
Section titled “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 (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(ajoutestaff.role/timestamps, créestaff_token+staff_action_log). À appliquer avant utilisation.