From 6d8f533deb1d835aa32e18fcad0aa23e0d17e2b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 11 Nov 2026 20:42:25 +0100 Subject: [PATCH] fix(auth): keep the page when sign-out is refused The sign-out mutation took every HTTP answer as success. A refused logout (403 from the demo guard, a 429, a 5xx) wiped the client caches and showed the sign-in page while the session cookie stayed valid, so the record pages opened again on the next visit. The call now goes through apiFetch, which throws on a non-2xx answer, and the error toast says the person is still signed in instead of naming a network error. --- messages/de.json | 1 + messages/en.json | 1 + messages/es.json | 1 + messages/fr.json | 1 + messages/it.json | 1 + messages/ko.json | 1 + messages/nl.json | 1 + messages/pl.json | 1 + messages/pt-BR.json | 1 + messages/ru.json | 1 + .../baselines/version-stamp-comments.json | 4 +- src/hooks/__tests__/use-logout.test.ts | 106 ++++++++++++++++++ src/hooks/use-auth.ts | 10 +- 13 files changed, 123 insertions(+), 7 deletions(-) create mode 100644 src/hooks/__tests__/use-logout.test.ts diff --git a/messages/de.json b/messages/de.json index 4dcc47c51..737f6dce7 100644 --- a/messages/de.json +++ b/messages/de.json @@ -529,6 +529,7 @@ "noAccount": "Noch kein Konto?", "hasAccount": "Bereits ein Konto?", "loginFailed": "Anmeldung fehlgeschlagen", + "signOutFailed": "Abmelden hat nicht geklappt. Du bist weiterhin angemeldet.", "registerFailed": "Registrierung fehlgeschlagen", "invitedBanner": "Du wurdest eingeladen. Erstelle dein Konto, auch wenn die Registrierung sonst geschlossen ist.", "alreadySignedIn": { diff --git a/messages/en.json b/messages/en.json index 6a5c05ac9..8877c35d5 100644 --- a/messages/en.json +++ b/messages/en.json @@ -529,6 +529,7 @@ "noAccount": "Don't have an account?", "hasAccount": "Already have an account?", "loginFailed": "Login failed", + "signOutFailed": "Could not sign out. You are still signed in.", "registerFailed": "Registration failed", "invitedBanner": "You have been invited. Create your account even while registration is otherwise closed.", "alreadySignedIn": { diff --git a/messages/es.json b/messages/es.json index a2b612bad..ad30b5020 100644 --- a/messages/es.json +++ b/messages/es.json @@ -529,6 +529,7 @@ "noAccount": "¿Aún no tienes cuenta?", "hasAccount": "¿Ya tienes cuenta?", "loginFailed": "No se ha podido iniciar sesión", + "signOutFailed": "No se pudo cerrar la sesión. Tu sesión sigue abierta.", "registerFailed": "No se ha podido completar el registro", "invitedBanner": "Te han invitado: crea tu cuenta aunque el registro esté cerrado.", "alreadySignedIn": { diff --git a/messages/fr.json b/messages/fr.json index af97df036..ed15c1527 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -529,6 +529,7 @@ "noAccount": "Pas encore de compte ?", "hasAccount": "Déjà un compte ?", "loginFailed": "Échec de la connexion", + "signOutFailed": "La déconnexion a échoué. Votre session est toujours ouverte.", "registerFailed": "Échec de l’inscription", "invitedBanner": "Vous avez été invité. Créez votre compte même si les inscriptions sont fermées.", "alreadySignedIn": { diff --git a/messages/it.json b/messages/it.json index fed51449c..bb602f60a 100644 --- a/messages/it.json +++ b/messages/it.json @@ -529,6 +529,7 @@ "noAccount": "Non hai ancora un account?", "hasAccount": "Hai già un account?", "loginFailed": "Accesso non riuscito", + "signOutFailed": "Non è stato possibile uscire. La sessione è ancora attiva.", "registerFailed": "Registrazione non riuscita", "invitedBanner": "Sei stato invitato: crea il tuo account anche se la registrazione è chiusa.", "alreadySignedIn": { diff --git a/messages/ko.json b/messages/ko.json index 77eee65e5..a7baf05aa 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -529,6 +529,7 @@ "noAccount": "계정이 없나요?", "hasAccount": "이미 계정이 있나요?", "loginFailed": "로그인 실패", + "signOutFailed": "로그아웃하지 못했어요. 아직 로그인되어 있어요.", "registerFailed": "회원가입 실패", "invitedBanner": "초대를 받았어요. 회원가입이 닫혀 있어도 계정을 만들 수 있어요.", "alreadySignedIn": { diff --git a/messages/nl.json b/messages/nl.json index 1df8e98bf..67807173c 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -529,6 +529,7 @@ "noAccount": "Nog geen account?", "hasAccount": "Heb je al een account?", "loginFailed": "Inloggen mislukt", + "signOutFailed": "Uitloggen is mislukt. Je bent nog steeds ingelogd.", "registerFailed": "Registratie mislukt", "invitedBanner": "Je bent uitgenodigd. Maak je account aan, ook al is registratie verder gesloten.", "alreadySignedIn": { diff --git a/messages/pl.json b/messages/pl.json index fa9457d77..6bc8109e9 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -529,6 +529,7 @@ "noAccount": "Nie masz jeszcze konta?", "hasAccount": "Masz już konto?", "loginFailed": "Logowanie nie powiodło się", + "signOutFailed": "Nie udało się wylogować. Sesja jest nadal aktywna.", "registerFailed": "Rejestracja nie powiodła się", "invitedBanner": "Otrzymano zaproszenie. Utwórz konto, nawet jeśli rejestracja jest zamknięta.", "alreadySignedIn": { diff --git a/messages/pt-BR.json b/messages/pt-BR.json index 6abf9a204..5efe0f404 100644 --- a/messages/pt-BR.json +++ b/messages/pt-BR.json @@ -529,6 +529,7 @@ "noAccount": "Não tem uma conta?", "hasAccount": "Já tem uma conta?", "loginFailed": "Falha no login", + "signOutFailed": "Não foi possível sair. Sua sessão continua ativa.", "registerFailed": "Falha no cadastro", "invitedBanner": "Você foi convidado. Crie sua conta mesmo que o cadastro esteja fechado.", "alreadySignedIn": { diff --git a/messages/ru.json b/messages/ru.json index 159cf4fab..412c87fa9 100644 --- a/messages/ru.json +++ b/messages/ru.json @@ -529,6 +529,7 @@ "noAccount": "Нет аккаунта?", "hasAccount": "Уже есть аккаунт?", "loginFailed": "Не удалось войти", + "signOutFailed": "Не удалось выйти. Вы по-прежнему в системе.", "registerFailed": "Не удалось зарегистрироваться", "invitedBanner": "Вас пригласили. Создайте аккаунт, даже если регистрация в остальном закрыта.", "alreadySignedIn": { diff --git a/src/__tests__/baselines/version-stamp-comments.json b/src/__tests__/baselines/version-stamp-comments.json index 58eb7415a..601533123 100644 --- a/src/__tests__/baselines/version-stamp-comments.json +++ b/src/__tests__/baselines/version-stamp-comments.json @@ -1,7 +1,7 @@ { "$comment": "Comment lines that open with a release version, per file under src/. Maintained by src/__tests__/version-stamp-ratchet.test.ts.", "count": 2890, - "total": 9216, + "total": 9215, "entries": { "__tests__/account-payload-consumer-guard.test.ts": 2, "__tests__/acting-account-boundary-guard.test.ts": 7, @@ -1474,7 +1474,7 @@ "hooks/use-account-switch.ts": 2, "hooks/use-ai-capability.ts": 3, "hooks/use-ai-run.ts": 1, - "hooks/use-auth.ts": 34, + "hooks/use-auth.ts": 33, "hooks/use-chart-domain-stats.ts": 1, "hooks/use-chart-overlay-prefs.ts": 2, "hooks/use-coach-message-results.ts": 1, diff --git a/src/hooks/__tests__/use-logout.test.ts b/src/hooks/__tests__/use-logout.test.ts new file mode 100644 index 000000000..ed0ba5e6e --- /dev/null +++ b/src/hooks/__tests__/use-logout.test.ts @@ -0,0 +1,106 @@ +/** + * Signing out: what the browser is left with when the server answers. + * + * `useMutation` is replaced by a stand-in that hands back the options, so the + * test drives `mutationFn`, `onSuccess` and `onError` the way React Query + * does, against the real fetch wrapper and a stubbed `fetch`. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +interface LogoutOptions { + mutationFn: () => Promise; + onSuccess: () => void; + onError: (failure: unknown) => void; +} + +const captured: { options: LogoutOptions | null } = { options: null }; +const push = vi.fn(); +const clear = vi.fn(); +const showErrorToast = vi.fn(); + +vi.mock("@tanstack/react-query", () => ({ + useQuery: vi.fn(), + useQueryClient: () => ({ clear }), + useMutation: (options: LogoutOptions) => { + captured.options = options; + return { mutate: vi.fn(), isPending: false }; + }, +})); +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push, replace: vi.fn() }), +})); +vi.mock("@/lib/i18n/context", () => ({ + useTranslations: () => ({ t: (key: string) => key }), +})); +vi.mock("@/lib/errors/error-toast", () => ({ + showErrorToast: (...args: unknown[]) => showErrorToast(...args), +})); +vi.mock("@/lib/pwa/query-persister", () => ({ + clearOfflineCachesForSessionEnd: vi.fn().mockResolvedValue(undefined), +})); + +import { useLogout } from "../use-auth"; + +const fetchMock = + vi.fn<(input: string, init?: RequestInit) => Promise>(); + +function json(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); +} + +/** What React Query does with the options: run, then settle one way. */ +async function settle(options: LogoutOptions): Promise { + try { + await options.mutationFn(); + } catch (failure) { + options.onError(failure); + return; + } + options.onSuccess(); +} + +beforeEach(() => { + vi.stubGlobal("fetch", fetchMock); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.clearAllMocks(); + captured.options = null; +}); + +describe("useLogout", () => { + it("clears the caches and opens the sign-in page once the server signed out", async () => { + fetchMock.mockResolvedValue(json(200, { data: { loggedOut: true } })); + + useLogout(); + await settle(captured.options!); + + expect(fetchMock).toHaveBeenCalledWith( + "/api/auth/logout", + expect.objectContaining({ method: "POST" }), + ); + expect(clear).toHaveBeenCalledTimes(1); + expect(push).toHaveBeenCalledWith("/auth/login"); + expect(showErrorToast).not.toHaveBeenCalled(); + }); + + it("stays on the page and says so when the server refuses", async () => { + // The demo instance answered 403 here; the page showed the sign-in form + // while the session cookie still opened every record page. + fetchMock.mockResolvedValue(json(403, { data: null, error: "Forbidden" })); + + useLogout(); + await settle(captured.options!); + + expect(push).not.toHaveBeenCalled(); + expect(clear).not.toHaveBeenCalled(); + expect(showErrorToast).toHaveBeenCalledWith( + "auth.signOutFailed", + expect.objectContaining({ cause: expect.anything() }), + ); + }); +}); diff --git a/src/hooks/use-auth.ts b/src/hooks/use-auth.ts index f5b9e4218..aa8344cdb 100644 --- a/src/hooks/use-auth.ts +++ b/src/hooks/use-auth.ts @@ -9,7 +9,7 @@ import { import { useRouter } from "next/navigation"; import { showErrorToast } from "@/lib/errors/error-toast"; import { queryKeys } from "@/lib/query-keys"; -import { apiGet, apiFetchRaw, ApiError } from "@/lib/api/api-fetch"; +import { apiGet, apiFetch, ApiError } from "@/lib/api/api-fetch"; import { adoptRecordFenceState } from "@/lib/api/record-fence"; import { retryOnceOnTransientError } from "@/lib/queries/retry-transient"; import { useMounted } from "@/hooks/use-mounted"; @@ -649,15 +649,15 @@ export function useLogout() { return useMutation({ mutationFn: async () => { - await apiFetchRaw("/api/auth/logout", { method: "POST" }); + // Throws on any non-2xx answer: a refused sign-out leaves the session + // alive, and showing the sign-in page then would claim otherwise. + await apiFetch("/api/auth/logout", { method: "POST" }); }, onSuccess: () => { clearCachesForSessionEnd(queryClient); router.push("/auth/login"); }, - // v1.16.4 — a network-failed logout used to do nothing at all (the - // menu closed, the session stayed); a toast names the failure. onError: (failure) => - showErrorToast(t("common.networkError"), { cause: failure }), + showErrorToast(t("auth.signOutFailed"), { cause: failure }), }); }