Skip to content

Profil utilisateur

Le champ sex exposé sur la ressource users n’est plus un entier brut mais un slug i18n stable :

Slug APICode interne (DB user.sex)Clé i18n complète
nullNULL (non renseigné)(omis)
male0users.sex.male
female1users.sex.female
other2users.sex.other

Le client lit attributes.sex === "male" puis résout le label localisé via son bundle i18n (users.sex.male → « Homme » / « Male » / …). La colonne DB reste un INT (0/1/2) — c’est de la sérialisation pure. Changer un slug est un breaking change API.

Permet à l’utilisateur courant de remplacer son username placeholder par un vrai username, et marque son profil comme complété (profile_completed_at = NOW()). Endpoint destiné en priorité aux comptes créés via Google OAuth (qui démarrent avec un username g_<8-hex> et profile_completed_at = NULL).

  • Auth : requise (Bearer)
  • Action : SetUsernameAction
  • Request body (forme plate ou JSON:API data.attributes) :
{ "username": "havoc" }
{
"jsonapi": { "version": "1.1" },
"data": {
"type": "users",
"id": "<user-uuid>",
"attributes": {
"username": "havoc",
"email": "user@gmail.com",
"isConfirmed": true,
"profileCompletedAt": "2026-06-07T10:00:00+00:00"
}
}
}
  • Réponse 400 — corps non-JSON

  • Réponse 401 — token absent / invalide

  • Réponse 409Profile already completed, meta.code = "profile.alreadyCompleted". Pour modifier un username déjà confirmé, utiliser POST /api/users/me/username/change.

  • Réponse 422username manquant ou validation échouée. Codes possibles :

    • username.tooShort, username.tooLong, username.invalidCharacters, username.invalidBoundary, username.consecutiveSeparators, username.reserved, username.alreadyTaken
  • Notes :

    • L’update est atomique (username + profile_completed_at + updated_at dans le même UPDATE).
    • Le username est normalisé (minuscules, trim) avant validation.
    • L’assignation est aussi enregistrée dans username_history (utilisée par /username/change pour le cooldown et la quarantaine).

Change le username d’un compte dont le profil est déjà complété (profile_completed_at IS NOT NULL). Pour les comptes OAuth dont le profil n’est pas encore complété (username placeholder g_<hex>), utiliser POST /api/users/me/username.

  • Auth : requise (Bearer)
  • Action : ChangeUsernameAction
  • Request body (forme plate ou JSON:API data.attributes) :
{ "username": "havoc2" }

Vérifications dans l’ordre, refus dès le premier échec :

  1. Profil complété — sinon 409 username.profileIncomplete. Utiliser l’endpoint de complétion du profil à la place.
  2. Format — règles de UsernamePolicy (3–32 caractères, [a-z0-9._-], bornes alphanumériques, pas de séparateurs consécutifs).
  3. Différent du username actuel — sinon 409 username.sameAsCurrent.
  4. Cooldown 30 jours — au moins 30 jours doivent s’être écoulés depuis la dernière assignation de username pour cet utilisateur (toute la chaîne, pas seulement l’actuel) — sinon 409 username.onCooldown avec meta.cooldownUntil (ISO-8601).
  5. Quarantaine 90 jours — si ce username a été libéré par un autre utilisateur il y a moins de 90 jours, il est en quarantaine — sinon 409 username.quarantined. Exception : l’utilisateur courant peut reprendre un username qu’il a lui-même tenu auparavant, sans attendre la quarantaine.
  6. Disponibilité immédiate — le username ne doit pas être détenu actuellement par un autre utilisateur — sinon 409 username.alreadyTaken.

L’update est atomique (transaction) : user.username + user.updated_at mis à jour, et username_history reçoit deux opérations :

  • la ligne ouverte (où released_at IS NULL) est fermée avec released_at = NOW() ;
  • une nouvelle ligne ouverte est insérée avec assigned_at = NOW().

profile_completed_at n’est pas touché.

  • Réponse 200 :
{
"jsonapi": { "version": "1.1" },
"data": {
"type": "usernameChanges",
"id": "<user-uuid>",
"attributes": {
"previousUsername": "havoc",
"username": "havoc2",
"cooldownDays": 30
}
}
}
  • Réponse 400 — corps non-JSON

  • Réponse 401 — token absent / invalide

  • Réponse 409 — code dans meta.code :

    • username.profileIncomplete
    • username.sameAsCurrent
    • username.onCooldown (avec meta.cooldownUntil et meta.cooldownDays)
    • username.quarantined (avec meta.quarantineDays)
    • username.alreadyTaken
  • Réponse 422username manquant ou format invalide. Codes : username.tooShort, username.tooLong, username.invalidCharacters, username.invalidBoundary, username.consecutiveSeparators, username.reserved

  • Notes :

    • Les durées (COOLDOWN_DAYS = 30, QUARANTINE_DAYS = 90) sont définies en constantes sur UsernameChangeService.
    • Le cooldown se calcule sur la dernière assignation toutes valeurs confondues (MAX assigned_at) — un user qui change pour X puis veut changer pour Y doit attendre 30 jours.

Définit ou efface le nickname de l’utilisateur courant : le nom d’affichage libre, modifiable à volonté et non unique (contrairement au username, handle unique encadré). Auth requise.

Règle d’affichage : displayName = nickname quand il est renseigné, sinon username (voir User::displayName()). Un nickname vide fait donc retomber l’affichage sur le username.

Corps (JSON plat ou data.attributes), la clé nickname doit être présente :

{ "nickname": "Jane Doe" } // définit
{ "nickname": "" } // efface (retombe sur le username)
{ "nickname": null } // efface

Validation (NicknamePolicy) — le nickname est volontairement permissif (lettres de tout script, chiffres, espaces, ponctuation/symboles, emojis) :

  • normalisation : trim + espaces internes multiples réduits à un seul ; chaîne vide ⇒ null (efface) ;
  • longueur 1..32 (colonne user.nickname = VARCHAR(32)) ;
  • pas de caractères de contrôle / invisibles (retours ligne, zero-width…).

Réponse 200 — la ressource users complète (mêmes attributs que GET /api/auth/me), donc le client voit immédiatement le nouveau displayName.

  • Réponse 400 — corps non-JSON.

  • Réponse 401 — token absent / invalide.

  • Réponse 422 — clé nickname absente, type invalide (ni string ni null), ou format invalide. Codes : nickname.tooShort, nickname.tooLong, nickname.invalidCharacters.

  • Notes :

    • Pas de contrainte d’unicité : deux utilisateurs peuvent partager un même nickname ; seul le username est unique.
    • La blocklist de noms réservés (username_blocklist.php) ne s’applique pas au nickname (choix produit : liberté d’affichage). À activer ici si la lutte contre l’usurpation par nom d’affichage devient nécessaire.

Définit ou change le mot de passe de l’utilisateur courant.

  • Auth : requise (Bearer)
  • Action : SetPasswordAction
  • Request body (forme plate ou JSON:API data.attributes) :
{
"currentPassword": "MyOldPassw0rd!",
"newPassword": "MyNewPassw0rd!"
}
  • currentPassword est requis si l’utilisateur a déjà un mot de passe (password_set_at IS NOT NULL), omis sinon (compte OAuth qui n’en a jamais défini).
  • newPassword est toujours requis et doit respecter la PasswordPolicy (mêmes règles que /auth/register).

Lors d’un changement réussi, toutes les autres sessions sont révoquées (déconnexion de tous les appareils sauf celui en cours). Le token courant reste valide.

  • Réponse 200 :
{
"jsonapi": { "version": "1.1" },
"data": {
"type": "passwordChanges",
"id": "<user-uuid>",
"attributes": {
"changedAt": "2026-06-07T10:30:00+00:00",
"otherSessionsRevoked": 3,
"hadPasswordBefore": true
}
}
}
  • Réponse 401 :

    • meta.code = "password.currentRequired"currentPassword manquant alors qu’il était attendu
    • meta.code = "password.currentInvalid"currentPassword incorrect
  • Réponse 409meta.code = "password.sameAsCurrent" (le nouveau mot de passe est identique à l’ancien)

  • Réponse 422newPassword absent ou échec de la PasswordPolicy. Codes : password.tooShort, password.tooLong, password.missingLowercase, password.missingUppercase, password.missingDigit, password.missingSpecial, password.invalidCharacters, password.invalidBoundary, password.containsUsername, password.containsEmail

  • Notes :

    • Le hash est bcrypt cost=12 (même paramètres que /auth/register).
    • password_set_at est mis à NOW() — utile pour audit ou logique métier ultérieure (“forcer renouvellement après N mois”).
    • La colonne password reste NOT NULL même pour les comptes OAuth (qui stockent un hash bcrypt d’octets aléatoires inutilisable). Pour distinguer “a un vrai mot de passe” : password_set_at IS NOT NULL / User::hasPassword().

Lie une identité Google au compte courant déjà authentifié. Utile pour résoudre le cas 409 oauth.linkRefused retourné par POST /api/auth/oauth/google (auto-link refusé par les règles C3) : l’utilisateur se connecte d’abord par mot de passe, puis confirme volontairement le rapprochement.

  • Auth : requise (Bearer)
  • Action : LinkGoogleAction
  • Request body (forme plate ou JSON:API data.attributes) :
{ "idToken": "<google id_token>" }

Vérifications dans l’ordre, refus dès le premier échec :

  1. Email vérifié côté Google (email_verified = true) — sinon 403 oauth.providerEmailNotVerified.
  2. Email Google = email du compte courant — sinon 409 oauth.emailMismatch. Empêche un attaquant de coller son Google sur le compte d’une victime, et empêche une victime trompée de lier le Google d’un attaquant.
  3. Pas déjà une identité Google sur ce compte — sinon 409 oauth.alreadyLinkedToThisUser. Un seul compte Google par user.
  4. Le sub Google n’est pas déjà lié à un autre user — sinon 409 oauth.alreadyLinkedToAnotherUser. La contrainte UNIQUE (provider, provider_user_id) garantit aussi cette règle au niveau base.
  • Réponse 201 :
{
"jsonapi": { "version": "1.1" },
"data": {
"type": "oauthIdentities",
"id": "<identity-uuid>",
"attributes": {
"provider": "google",
"emailAtLink": "user@gmail.com",
"createdAt": "2026-06-07T10:45:00+00:00"
}
}
}
  • Réponse 400 — corps non-JSON

  • Réponse 401 — id_token non vérifiable (signature, iss, aud, exp…)

  • Réponse 403oauth.providerEmailNotVerified

  • Réponse 409 — un des trois codes : oauth.emailMismatch, oauth.alreadyLinkedToThisUser, oauth.alreadyLinkedToAnotherUser

  • Réponse 422idToken manquant

  • Notes :

    • Pas d’émission de session — l’utilisateur est déjà connecté avec son token Hydrogen, on ne fait que créer la liaison.
    • Une future requête POST /api/auth/oauth/google avec le même id_token signera le user via le chemin existingIdentity (200).

Lie une identité Apple au compte courant déjà authentifié. C’est le chemin de récupération après un 409 oauth.linkRefused retourné par POST /api/auth/oauth/apple (rappel : Apple n’est jamais auto-lié, politique E.a stricte).

  • Auth : requise (Bearer)
  • Action : LinkAppleAction
  • Request body (forme plate ou JSON:API data.attributes) :
{ "idToken": "<apple id_token>" }

Vérifications dans l’ordre, refus dès le premier échec :

  1. email_verified Apple doit être vrai — sinon 403 oauth.providerEmailNotVerified.
  2. Email Apple = email du compte courant — sinon 409 oauth.emailMismatch. Une alias Private Relay (@privaterelay.appleid.com) est traité comme un email normal pour la comparaison stricte.
  3. Pas déjà une identité Apple sur ce compte — sinon 409 oauth.alreadyLinkedToThisUser.
  4. Le sub Apple n’est pas déjà lié à un autre user — sinon 409 oauth.alreadyLinkedToAnotherUser.
  • Réponse 201 :
{
"jsonapi": { "version": "1.1" },
"data": {
"type": "oauthIdentities",
"id": "<identity-uuid>",
"attributes": {
"provider": "apple",
"emailAtLink": "abc123@privaterelay.appleid.com",
"emailIsRelay": true,
"createdAt": "2026-06-07T10:45:00+00:00"
}
}
}
  • Réponses d’erreur : 400, 401, 403, 409 (trois codes), 422 — mêmes formes que pour Google avec provider: "apple" dans les messages.

Lie une identité Facebook au compte courant déjà authentifié. Identique en intention à oauth/apple mais accepte les deux flows Facebook.

// Flux classique
{ "flow": "classic", "accessToken": "<facebook access token>" }
// Flux limited
{ "flow": "limited", "idToken": "<facebook OIDC id_token>" }
  1. Facebook a fourni un email — sinon 422 oauth.facebook.emailMissing. Pas de signal email_verified côté Facebook (cf. matrice), donc l’étape Google « providerEmailNotVerified » est sans objet ici.
  2. Email Facebook = email du compte courant — sinon 409 oauth.emailMismatch.
  3. Pas déjà une identité Facebook sur ce compte — sinon 409 oauth.alreadyLinkedToThisUser.
  4. Le sub Facebook n’est pas déjà lié à un autre user — sinon 409 oauth.alreadyLinkedToAnotherUser.
  • Réponse 201 — même forme avec provider: "facebook", emailIsRelay: false.
  • Réponse 401 — token non vérifiable.
  • Réponse 409 — codes oauth.emailMismatch / oauth.alreadyLinkedToThisUser / oauth.alreadyLinkedToAnotherUser.
  • Réponse 422flow invalide, token manquant pour le flux choisi, ou oauth.facebook.emailMissing.

Liste toutes les identités OAuth liées au compte courant. Renvoie une collection JSON:API, possiblement vide pour un compte créé par mot de passe sans lien OAuth.

{
"jsonapi": { "version": "1.1" },
"data": [
{
"type": "oauthIdentities",
"id": "<identity-uuid>",
"attributes": {
"provider": "google",
"emailAtLink": "user@gmail.com",
"emailIsRelay": false,
"linkedAt": "2026-06-07T10:45:00+00:00"
}
}
],
"meta": { "count": 1 }
}
  • Réponse 401 — token absent / invalide

  • Notes :

    • Tri par created_at ASC (plus ancienne liaison en premier).
    • provider_user_id (le sub du provider) n’est pas exposé — il n’a aucun usage côté client.
    • emailAtLink est l’email retourné par le provider au moment de la liaison (peut différer de l’email actuel du user).
    • emailIsRelay vaut true uniquement pour les identités Apple créées avec un alias Private Relay (@privaterelay.appleid.com). Toujours false pour Google et Facebook.

Retourne le snapshot complet des préférences de l’utilisateur courant : les valeurs par défaut fusionnées avec les éventuels overrides stockés en base. Le client n’a jamais à gérer le cas « préférence pas encore définie » — chaque clé connue est toujours présente.

  • Auth : requise (Bearer)

  • Action : GetSettingsAction

  • Pas de body (GET)

  • Réponse 200 :

{
"jsonapi": { "version": "1.1" },
"data": {
"type": "userSettings",
"id": "<user-uuid>",
"attributes": {
"locale": "fr-FR",
"timezone": "Europe/Paris",
"theme": "system",
"currency": "EUR",
"profileVisibility": "public",
"notificationsEmail": true,
"notificationsPush": true,
"showSensitiveContent": false,
"emailVisibleOnProfile": false
}
}
}
  • Réponse 401 — token absent / invalide

Définies dans UserSettingRegistry :

CléTypeDéfautValeurs autorisées
localelocalefr-FRlocale effectivement supportée par l’app (voir GET /api/i18n/locales)
timezonetimezoneEurope/Parisidentifiant IANA (DateTimeZone::listIdentifiers())
themeenumsystemlight, dark, system
currencycurrencyEURcode ISO 4217 présent dans la table currency
profileVisibilityenumpublicpublic, private
notificationsEmailbooltrue
notificationsPushbooltrue
showSensitiveContentboolfalse
emailVisibleOnProfileboolfalse

Met à jour partiellement les préférences de l’utilisateur courant.

  • Auth : requise (Bearer)
  • Action : UpdateSettingsAction
  • Request body (forme plate ou JSON:API data.attributes) — toutes les clés sont optionnelles, seules les clés présentes sont touchées :
{
"theme": "dark",
"notificationsEmail": false
}

Si une seule clé/valeur échoue à la validation, aucune écriture n’a lieu et la réponse 422 énumère toutes les erreurs. Le client ne se retrouve jamais avec un état mi-appliqué.

La table user_preference ne stocke que les overrides — pas les défauts.

  • Si la valeur écrite égale le défaut actuel, la ligne correspondante est supprimée.
  • Sinon, elle est upsertée (INSERT … ON DUPLICATE KEY UPDATE).

Conséquence : changer un défaut dans le code propage automatiquement à tous les comptes qui n’ont pas d’override explicite.

  • Réponse 200 — snapshot complet post-update + meta.appliedKeys listant les clés réellement écrites/supprimées :
{
"jsonapi": { "version": "1.1" },
"data": {
"type": "userSettings",
"id": "<user-uuid>",
"attributes": {
"locale": "fr-FR",
"timezone": "Europe/Paris",
"theme": "dark",
"currency": "EUR",
"profileVisibility": "public",
"notificationsEmail": false,
"notificationsPush": true,
"showSensitiveContent": false,
"emailVisibleOnProfile": false
}
},
"meta": { "appliedKeys": ["theme", "notificationsEmail"] }
}
  • Réponse 400 — corps non-JSON ou non-objet

  • Réponse 422 — un ou plusieurs errors[] avec source.pointer = /data/attributes/<key> et meta.code parmi :

    • setting.unknown — clé inconnue du registry
    • setting.expectedBool — la valeur d’une clé booléenne n’est pas un true/false
    • setting.invalidEnumValue — valeur absente de la liste autorisée
    • setting.invalidLocale — locale non supportée par l’app (cf. GET /api/i18n/locales)
    • setting.invalidTimezone — identifiant IANA inconnu
    • setting.invalidCurrency — code ISO 4217 inconnu de la table currency
  • Notes :

    • Un body vide ({}) est accepté et retourne 200 avec appliedKeys: [].
    • Les booléens sont strictement typés : les chaînes "true"/"false" sont rejetées (setting.expectedBool).
    • Le ramassage des erreurs est exhaustif : tous les champs invalides sont signalés en un seul appel, pas seulement le premier.

Téléverse (ou remplace) l’avatar de l’utilisateur authentifié.

  • Auth : requise (Bearer)

  • Action : UploadAvatarAction

  • Body : multipart/form-data avec un unique champ fichier avatar.

    • Formats acceptés : JPEG, PNG, WebP, GIF, HEIC, HEIF.
    • Taille maximale : AVATAR_MAX_UPLOAD_BYTES (par défaut 256 000 octets / 256 ko).
  • Pipeline (AvatarUploadService) :

    1. Validation (taille, code d’erreur PSR-7, non vide).
    2. Décodage Imagick — un fichier non décodable ⇒ 422 invalidImage.
    3. Whitelist du format source. Animations (GIF/HEIF) : seule la première frame est conservée.
    4. Auto-orientation EXIF puis strip complet des métadonnées (GPS, appareil, etc. — privacy).
    5. Redimension bestfit à AVATAR_MAX_DIMENSION (256px par défaut ; pas d’upscale).
    6. Encodage WebP qualité AVATAR_WEBP_QUALITY (80 par défaut, webp:method=6).
    7. Écriture atomique (tmp + rename) sous <AVATAR_STORAGE_PATH>/user/AA/BB/CC/<uuid-hex>/avatar.webp (3 niveaux de dossiers ventilés à partir des 6 premiers caractères hex de l’UUID).
    8. Mise à jour de user.avatar_updated_at (cache-buster).
    9. Re-hydratation de l’utilisateur.
  • Réponse 200 OK : ressource users complète avec avatarUrl actualisée (et son cache-buster ?v=<timestamp>).

{
"data": {
"type": "users",
"id": "<uuid>",
"attributes": {
"avatarUrl": "http://hexatrip-static.dev.com/user/c2/1e/78/c21e7856eb524c8cb9a7786a2f80ce7e/avatar.webp?v=1812345678",
"hasAvatar": true,
"…": ""
}
}
}
  • Réponses d’erreur (mapping AvatarErrorCode → HTTP) :

    • 400 avatar.uploadFailed — échec côté transport multipart
    • 413 avatar.tooLarge — fichier > AVATAR_MAX_UPLOAD_BYTES
    • 415 avatar.unsupportedFormat — format hors whitelist
    • 422 avatar.empty — pas de fichier ou octets vides (source.pointer = /data/attributes/avatar)
    • 422 avatar.invalidImage — octets non décodables comme image
    • 500 avatar.encodingFailed — Imagick a refusé de produire le WebP
    • 500 avatar.storageWriteFailed — écriture disque échouée
  • Notes :

    • L’avatar est toujours écrit à la même URL (avatar.webp). C’est ?v=<unix-ts> (basé sur avatar_updated_at) qui force l’invalidation cache navigateur/CDN entre deux uploads.
    • Quand l’utilisateur n’a pas d’avatar, avatarUrl pointe vers <AVATAR_PUBLIC_URL>/user/default-avatar.webp (pas de cache-buster — c’est un fichier ops).

Supprime l’avatar de l’utilisateur authentifié (fichier sur disque + timestamp en base). L’utilisateur retombe sur l’avatar par défaut partagé.

  • Auth : requise (Bearer)
  • Action : DeleteAvatarAction
  • Body : aucun
  • Idempotent : appelé sur un compte qui n’a déjà plus d’avatar, retourne quand même 200.
  • Réponse 200 OK : ressource users complète avec avatarUrl ré-orientée vers le default avatar et hasAvatar: false.

Téléverse (ou remplace) la cover de profil de l’utilisateur authentifié.

  • Auth : requise (Bearer)

  • Action : UploadCoverAction

  • Body : multipart/form-data avec un unique champ fichier cover.

    • Formats acceptés : JPEG, PNG, WebP, GIF, HEIC, HEIF.
    • Taille maximale : COVER_MAX_UPLOAD_BYTES (par défaut 600 000 octets / 600 ko).
  • Pipeline (CoverUploadService) : identique à l’upload d’avatar, à deux différences près :

    • Redimension bestfit à l’intérieur de COVER_MAX_WIDTH × COVER_MAX_HEIGHT (par défaut 1500 × 500 px), aspect ratio conservé — une image 3000 × 800 sera réduite à 1500 × 400 (pas d’upscale, pas de crop).
    • Le fichier est écrit dans le même dossier ventilé que l’avatar, mais sous le nom cover.webp : <AVATAR_STORAGE_PATH>/user/AA/BB/CC/<uuid-hex>/cover.webp.
  • Effet de bord : user.cover_updated_at est mis à jour (cache-buster URL).

  • Réponse 200 OK : ressource users complète avec coverUrl actualisée (et son cache-buster ?v=<timestamp>).

{
"data": {
"type": "users",
"id": "<uuid>",
"attributes": {
"coverUrl": "http://hexatrip-static.dev.com/user/c2/1e/78/c21e7856eb524c8cb9a7786a2f80ce7e/cover.webp?v=1812345678",
"hasCover": true,
"…": ""
}
}
}
  • Réponses d’erreur (mapping CoverErrorCode → HTTP) :

    • 400 cover.uploadFailed — échec côté transport multipart
    • 413 cover.tooLarge — fichier > COVER_MAX_UPLOAD_BYTES
    • 415 cover.unsupportedFormat — format hors whitelist
    • 422 cover.empty — pas de fichier ou octets vides (source.pointer = /data/attributes/cover)
    • 422 cover.invalidImage — octets non décodables comme image
    • 500 cover.encodingFailed — Imagick a refusé de produire le WebP
    • 500 cover.storageWriteFailed — écriture disque échouée
  • Notes :

    • Comme pour l’avatar, l’URL est toujours cover.webp ; c’est ?v=<unix-ts> qui force l’invalidation cache.
    • Sans cover, coverUrl pointe vers <AVATAR_PUBLIC_URL>/user/default-cover.webp (sans cache-buster).

Supprime la cover de l’utilisateur authentifié (fichier sur disque + timestamp en base). L’utilisateur retombe sur la cover par défaut partagée.

  • Auth : requise (Bearer)
  • Action : DeleteCoverAction
  • Body : aucun
  • Idempotent : appelé sur un compte qui n’a déjà plus de cover, retourne quand même 200.
  • Réponse 200 OK : ressource users complète avec coverUrl ré-orientée vers la default cover et hasCover: false.