Skip to content

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 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).


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

ParamValeursDéfautNotes
confirmedtrue | falseaucunconfirmed_at IS [NOT] NULL. Valeur inconnue = filtre ignoré.
bannedtrue | falseaucunBan actif (banned_until > NOW()), pas la simple présence d’une empreinte.
verifiedtrue | falseaucunis_verified.
deletedtrue | falseaucundeleted_at IS [NOT] NULL (soft-delete RGPD).
limit1..10050Borné en dur côté serveur.
cursorAtISO-8601 datetimeaucunjoined_at du dernier item de la page précédente. À fournir avec cursorId (les deux ou aucun).
cursorIdhex (32 chars)aucunid 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

Terminal window
# 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

StatusBodySens
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

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

Terminal window
# 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).


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

Terminal window
# 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).


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

ParamValeursDéfautNotes
qstring""Requête full-text (username / nickname). Vide = parcours pur.
sortpopular | recentpertinencepopularstats.num_user_follower:desc ; recentjoined_at:desc ; sinon ranking Meilisearch.
limit1..10050Borné en dur (aligné sur GET /admin/users).
offset0+0Pagination 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

StatusBodySens
503{ "error": "Search backend unavailable.", "detail": "…" }Meilisearch injoignable
403{ "error": "..." }auth KO

Exemple curl

Terminal window
# 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.

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

StatusBodySens
200enveloppe { jsonapi, data:{ type:"users", id, attributes } } (id = UUID dashé)trouvé
404erreur JSON:API (errors[].title = "User not found")row absente ou hex malformé
403{ "error": "..." }auth KO

Exemple curl

Terminal window
curl -s -H "Authorization: Bearer $ADMIN_API_TOKEN" \
-H "Accept: application/vnd.api+json" \
http://hydrogen.dev.com/admin/users/d26d1600cde54bd095e09f8b68ace05f

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

StatusBodySens
200objet 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

Terminal window
curl -s -H "Authorization: Bearer $ADMIN_API_TOKEN" \
http://hydrogen.dev.com/admin/users/d26d1600cde54bd095e09f8b68ace05f/address

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

TransitionUPDATE userSide-effects
none (déjà à l’état demandé)nonaucun
verify (0 → 1)ouiaucun (pas de notif, pas de XP)
unverify (1 → 0)ouiaucun

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

StatusBodySens
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

Terminal window
# 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.

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.

Bodymultipart/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)

StatusBodySens
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

Terminal window
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"

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)

StatusBodySens
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.

Terminal window
curl -X DELETE \
-H "Authorization: Bearer $ADMIN_API_TOKEN" \
"http://hydrogen.dev.com/admin/users/d26d1600cde54bd095e09f8b68ace05f/avatar"

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.

Bodymultipart/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)

StatusBodySens
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.

Terminal window
curl -X POST \
-H "Authorization: Bearer $ADMIN_API_TOKEN" \
-F "cover=@/path/to/banner.jpg" \
"http://hydrogen.dev.com/admin/users/d26d1600cde54bd095e09f8b68ace05f/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)

StatusBodySens
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").

Terminal window
curl -X DELETE \
-H "Authorization: Bearer $ADMIN_API_TOKEN" \
"http://hydrogen.dev.com/admin/users/d26d1600cde54bd095e09f8b68ace05f/cover"

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 (sentinelle 9999-12-31T23:59:59).
  • reason = raison interne (note libre, ≤ 500 caractères) persistée sur user.ban_reason. Visible back-office uniquement (AdminUserSerializerGET /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

TransitionSensSessions révoquées
banl’utilisateur n’était pas banni (aucun ban actif)oui
updatel’utilisateur était déjà banni — la fenêtre est prolongée/raccourcieoui (souvent 0, plus de session valide)

Path params

  • hex : id de l’utilisateur en 32 hex lowercase.

Réponses

StatusBodySens
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

Terminal window
# 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.

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

TransitionUPDATE userSens
unbanouiune empreinte de ban existait (banned_until non NULL, active ou expirée) → effacée
nonenonaucune empreinte de ban (banned_until déjà NULL) → no-op idempotent

Réponses

StatusBodySens
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

Terminal window
curl -X PUT \
-H "Authorization: Bearer $ADMIN_API_TOKEN" \
http://hydrogen.dev.com/admin/users/d26d1600cde54bd095e09f8b68ace05f/unban

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

TransitionSensÉcritures
anonymizele compte n’était pas encore purgé → effacé/anonymisésessions + médias + scrub user + Meili + tokens
nonecompte déjà anonymisé (purged_at non nul) → no-op idempotentaucune

Path params

  • hex : id de l’utilisateur en 32 hex lowercase.

Réponses

StatusBodySens
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

Terminal window
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}.

É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) :

ChampTypeValidation
usernamestringnormalisé (lowercase+trim), policy (3..32, [a-z0-9._-], bornes, blocklist), unique. Appliqué via changeUsername() (history préservé) sans quarantaine/cooldown self-service.
nicknamestring | nullnormalisé (collapse espaces), policy (1..32, pas de caractères de contrôle). null (ou vide) = efface (fallback username).
namestring | nulltexte libre ; vide ⇒ null.
firstnamestring | nulltexte libre ; vide ⇒ null.
emailstringformat RFC + ≤ 255 chars, unique.
biostring | nulltexte libre ; vide ⇒ null.
reasonstringraison libre journalisée (optionnel).

Comportement par transition

TransitionSensÉcritures
updateau moins un champ change effectivement1..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)
noneaucun changement effectif (valeurs identiques)aucune (ni DB, ni audit, ni reindex)

Path params

  • hex : id de l’utilisateur en 32 hex lowercase.

Réponses

StatusBodySens
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

Terminal window
# 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).

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

ParamValeursDéfautNotes
limit1..10050borné en dur.
beforeentier positifaucunid 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

StatusBodySens
200enveloppe { 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

Terminal window
# 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.

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 (format user.id BINARY(16) → hex lowercase).

Réponses

StatusBodySens
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

Terminal window
curl -X POST \
-H "Authorization: Bearer $ADMIN_API_TOKEN" \
"http://hydrogen.dev.com/admin/users/d26d1600cde54bd095e09f8b68ace05f/reindex"

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

ParamTypeDéfautMinMax
cursorhex (32 chars)null (début)
batchSizeint20011000

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
}
ChampSens
processedutilisateurs indexés avec succès dans ce batch
removedrows absents en DB ou purgés dont le doc Meili stale a été purgé
failedliste des erreurs par-utilisateur — n’interrompt pas le batch
lastIddernier id parcouru dans le batch (null si batch vide)
nextCursorà passer en ?cursor= au prochain appel ; null quand done=true
donetrue quand le batch a renvoyé moins de rows que demandé → fin du backfill
totalAllCOUNT(*) user au moment de l’appel — pour reporter une progression côté caller
durationMslatence serveur du batch

Erreurs

StatusBody
400{ "error": "Invalid cursor." }
400{ "error": "Invalid batchSize." }
403{ "error": "..." }

Pattern d’utilisation (Talend / curl boucle)

Terminal window
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

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.

Trois rôles, ordonnés par privilège (un numérique exprime « au moins ce niveau ») :

RôleslugAccès
Consultantconsultantlecture seule de certaines stats
Modérateurmoderator+ actions de modération (accès limité)
Adminadminaccè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).

Terminal window
# 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).

MéthodeCheminRôle requisDescription
POST/admin/staff/login— (public)Échange identifiants → token bearer
POST/admin/staff/logouttout staffRévoque la session courante (204)
GET/admin/staff/metout staffProfil de l’opérateur courant (dont son rôle)
GET/admin/staffadminListe des opérateurs (keyset ?after/?limit)
POST/admin/staffadminCrée un opérateur (201)
GET/admin/staff/{id}adminDétail d’un opérateur
PUT/admin/staff/{id}adminMet à jour (partiel ; password optionnel)
DELETE/admin/staff/{id}adminSupprime (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é.

« 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).

ColonneTypeDescription
idBIGINT PKauto-incrément
staff_idINTacteur (null = tentative non authentifiée, ex. login raté)
usernameVARCHARidentifiant dénormalisé de l’acteur
actionVARCHARlabel logique (staff.login, staff.request, …)
method / path / statusrequête HTTP + statut final
metadataJSONcontexte optionnel
ip_address / user_agentprovenance
created_atDATETIME

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.