Skip to content

fix(mobile): couper la session d'un compte supprimé et la boucle /me - #480

Merged
PaulThiberville merged 3 commits into
mainfrom
issue/475-deleted-account-signout
Oct 15, 2026
Merged

PaulThiberville merged 3 commits into
mainfrom
issue/475-deleted-account-signout

Conversation

@PaulThiberville

Copy link
Copy Markdown
Collaborator

Résultat et problème

Après « Supprimer mon compte », l'app envoyait GET /v1/me en boucle (environ 8 par seconde, refusés en 401), et la feuille de connexion ne s'ouvrait plus. Trois causes, toutes reproduites en local sur le code de main, et un commit pour chacune :

  1. La boucle de GET /me (7c05942). useMe rendait un refetch neuf à chaque rendu, et l'onglet Profil le passe à useFocusEffect. Chaque rendu relançait donc l'effet, qui lançait une requête, qui provoquait un rendu. Connecté, cela faisait déjà 42 GET /me en 5 s. Une fois anonyme, refetch ignorait enabled: uid !== null et partait sans jeton, d'où les 401 sans fin. Désormais refetch est stable et ne fait rien sans compte.
  2. Un compte supprimé restait connecté (9a48a2b). La session ne suivait que onUserChanged de Firebase. La nouvelle fonction session.endSession() passe l'app anonyme tout de suite : le jeton n'est plus envoyé et la query me est désactivée. Elle demande ensuite la déconnexion Firebase, une seule fois, même si plusieurs requêtes échouent en même temps. Un échec de Firebase part dans Sentry sans laisser l'app connectée. Elle est appelée après DELETE /me et sur un 401 « Account deleted » (signOutOnDeletedAccount). requireAccount traite un compte supprimé comme une absence de compte, ouvre la feuille et ne lance plus l'action. Une connexion réussie pose l'utilisateur dans la session sans attendre Firebase, qui peut ne rien signaler pour le compte qu'il garde (Aligner la suppression de compte sur D-025 et permettre la réinscription #128).
  3. La feuille ne s'ouvrait plus (74b3ff3). Quand on la fermait par le voile ou par un glissement, gorhom prévenait le flux, puis l'état fermé du flux la refermait une seconde fois. gorhom ne la présentait plus ensuite : auth_prompted partait, sans feuille. C'est le symptôme vu sur le Note 9.

Limite restante : le scénario « déconnexion Firebase muette » n'a été recetté qu'avec un faux Firebase. Sur un vrai build, le cas nominal reste à constater (voir plus bas).

Fixes #475
Écrans : E-09, E-10
Dépendance : aucune

Comment vérifier

Tests unitaires (un test par cause, chacun échoue sur le code de main) :

  • src/profile/meRequests.test.tsx : l'onglet Profil sur une API simulée qui compte ses requêtes (src/test/fakeApi.ts). On vérifie au plus deux GET /me à l'ouverture et au plus un après la suppression, y compris quand la déconnexion Firebase échoue. Sur un 401 « Account deleted » avec un Firebase muet, il y a exactement un GET /me et une seule déconnexion, puis la reconnexion donne un compte neuf. Sur main : « The focus effect of the Profil tab runs in a loop ».
  • session.test.ts : endSession passe anonyme tout de suite et une seule fois, même quand Firebase échoue, et une nouvelle connexion est reprise.
  • flow.test.ts : un compte supprimé ouvre la feuille. Hors ligne, l'action part sans couper la session.
  • SignInSheet.test.tsx : la feuille se rouvre après une fermeture sur le voile, sans second dismiss.

Recette locale, le 2026-10-15, par l'agent CODE. Dev builds sur l'émulateur Android Medium_Phone (API 34) et le simulateur iPhone 17 Pro (iOS 26.4). API locale sur une base de recette dédiée, migrée jusqu'à 0014 puis semée. Faux Firebase par patch local jamais commité, dans deux variantes : déconnexion muette (Firebase garde l'utilisateur, le pire cas) et déconnexion réelle (le cas de staging). Comptages tirés du journal de l'API :

Scénario main Cette PR
Connecté sur Profil, 5 s 42 GET /me 200 2 à l'ouverture, puis 1 par navigation
Après DELETE /me, Firebase muet, 8 s 93 GET /me 401, app restée connectée 0 GET /me, app anonyme
Après DELETE /me, Firebase réel, 8 s 97 GET /me 401, profil anonyme affiché 0 GET /me
« Créer un compte » après une fermeture sur le voile pas de feuille feuille rouverte
« Plan » / « Programmer » après la suppression — feuille « Connectez-vous pour programmer ce parcours », rouverte après le voile
Reconnexion Google — compte neuf en base (l'ancien reste deleted_at), étape « Bienvenue »

Android : les deux variantes. iOS : la variante muette. Texte système à 150 % (Android) : la feuille de connexion reste lisible et complète.

Avant (main) : boucle sur un profil anonyme, puis feuille absente après le voile.

android-before-fix-loop-anonymous.png
android-before-fix-no-sheet-after-veil.png

Après :

android-a2-signed-in.png
android-a3-after-delete.png
android-a4-plan-opens-sheet.png
android-a5-resignin.png
android-a6-sheet-150.png
ios-3-after-delete.png
ios-4-plan-opens-sheet.png

  • pnpm lint && pnpm typecheck && pnpm test verts en local, et lint, typecheck et tests mobile verts commit par commit (git rebase --exec)
  • Testé sur iOS (simulateur) et Android (émulateur), dev builds avec faux Firebase. Reste à constater sur un vrai build preview Android avec Firebase et Google.

Formatage mécanique : non

Risque

Niveau : 2 — on change la gestion de la session et l'envoi du jeton côté mobile. L'effet est réversible et circonscrit : aucune migration, aucune donnée persistée, rien côté API.

Taille : 7c05942 +183/−1, dont 172 de test (meRequests.test.tsx, fakeApi.ts) · 9a48a2b +170/−21, dont 112 de test · 74b3ff3 +25/−0. Chaque commit est sous 400 lignes.

Revue OWASP Top 10 (2025), ciblée :

  • A07:2025 Authentication Failures : un compte supprimé n'envoie plus son jeton. getToken rend null dès que la session est anonyme, sans attendre Firebase (test session.test.ts). La déconnexion Firebase est toujours demandée. Si elle échoue, l'app reste anonyme et l'échec part dans Sentry. Une session neuve n'est ouverte que par une connexion qui a abouti (signedIn(result.user)). Côté API, le contrôle est inchangé : l'ancien jeton reste refusé (« Account deleted », D-027).
  • A06:2025 Insecure Design et A03:2025 Software Supply Chain Failures : aucune nouvelle dépendance. La boucle de requêtes, une forme de déni de service involontaire depuis le client, ne tourne plus. Une limite de débit côté API est proposée sur l'Inbox 📥 Inbox — Triage #4, sans être codée ici.
  • A10:2025 Mishandling of Exceptional Conditions : l'échec de la déconnexion Firebase ne laisse plus l'app dans un état connecté. Hors ligne, requireAccount lance l'action sans couper la session (test flow.test.ts).
  • A09:2025 Security Logging and Alerting Failures : l'échec de la déconnexion Firebase est envoyé à Sentry, sans jeton ni donnée personnelle.

Retour arrière

Un revert de la PR suffit : aucune migration, aucune donnée créée. Après le revert, la boucle de GET /me revient.

🤖 Generated with Claude Code

PaulThiberville and others added 3 commits October 15, 2026 12:24
useMe gave a new refetch at each render, and ProfileScreen passes it to
useFocusEffect: each render ran the effect again, hence a request, hence
a render. Signed in, it asked /me about ten times a second; signed out,
refetch ignored `enabled` and sent /me without token, refused in a loop.
refetch is now stable and does nothing without account.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
After DELETE /me, or on a 401 « Account deleted », session.endSession
makes the app anonymous at once, its token no longer sent, then asks
Firebase to sign out: a failure no longer leaves the app signed in, and
goes to Sentry. Called again while anonymous, it does nothing, so the
sign-out runs once however many queries fail. requireAccount treats a
deleted account as no account and opens the sheet, and a sign-in that
goes through sets the session user without waiting for Firebase.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Closed by the veil or a swipe, the sheet told the flow, whose closed
state dismissed the sheet a second time: gorhom then no longer presented
it, and every action that asks for an account sent auth_prompted with no
sheet, on « Programmer » as on « Créer un compte ou se connecter ».

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@PaulThiberville PaulThiberville added the level:2 Risque niveau 2 (SECURITY.md) label Oct 15, 2026
@PaulThiberville

Copy link
Copy Markdown
Collaborator Author

Plan d'implémentation et décisions techniques

  • Un commit par cause, chacun autonome et vert seul (lint, typecheck et tests mobile rejoués par git rebase --exec), dans l'ordre boucle → session → feuille.
  • La session décide, pas Firebase : endSession() passe la session anonyme avant d'appeler Firebase. On ne fait plus confiance à onUserChanged pour sortir d'un compte supprimé : la recette montre qu'un Firebase muet suffit à garder l'app connectée et à relancer la boucle. Le garde-fou « une seule fois » vient de l'état lui-même : appelée hors de l'état signedIn, la fonction ne fait rien, donc pas de compteur ni de verrou à part.
  • refetch de useMe sans effet sans compte : query.refetch() de TanStack ignore enabled. C'est la garde côté client qui empêche toute requête sans jeton depuis l'onglet Profil.
  • requireAccount ne distingue que « compte supprimé » (session coupée, feuille ouverte) et « autre échec » (hors ligne : l'action part, comme avant). Un 401 d'un autre type reste traité par le rafraîchissement du jeton dans packages/api-client, qui n'est pas modifié.
  • API simulée de test (src/test/fakeApi.ts) : elle compte GET et DELETE /me et répond comme le serveur (« Missing bearer token », « Account deleted »), pour mesurer une boucle au lieu de la supposer.
  • Pas de limite de débit côté API dans cette PR : elle est proposée sur l'Inbox 📥 Inbox — Triage #4.

@PaulThiberville
PaulThiberville merged commit 80fcd50 into main Oct 15, 2026
4 checks passed
@PaulThiberville
PaulThiberville deleted the issue/475-deleted-account-signout branch October 15, 2026 10:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

level:2 Risque niveau 2 (SECURITY.md)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Après la suppression du compte, l'app reste connectée et appelle GET /me en boucle (Android)

1 participant