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.
This commit is contained in:
Marc-André Bombeck committed 2026-11-11 20:42:25 +01:00
1 parent 31f296a03e
commit 6d8f533deb
13 files changed
+123 -7

No files matched your search

+1
View File
@@ -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": {
+1
View File
@@ -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": {
+1
View File
@@ -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": {
+1
View File
@@ -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": {
+1
View File
@@ -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": {
+1
View File
@@ -529,6 +529,7 @@
"noAccount": "계정이 없나요?",
"hasAccount": "이미 계정이 있나요?",
"loginFailed": "로그인 실패",
"signOutFailed": "로그아웃하지 못했어요. 아직 로그인되어 있어요.",
"registerFailed": "회원가입 실패",
"invitedBanner": "초대를 받았어요. 회원가입이 닫혀 있어도 계정을 만들 수 있어요.",
"alreadySignedIn": {
+1
View File
@@ -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": {
+1
View File
@@ -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": {
+1
View File
@@ -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": {
+1
View File
@@ -529,6 +529,7 @@
"noAccount": "Нет аккаунта?",
"hasAccount": "Уже есть аккаунт?",
"loginFailed": "Не удалось войти",
"signOutFailed": "Не удалось выйти. Вы по-прежнему в системе.",
"registerFailed": "Не удалось зарегистрироваться",
"invitedBanner": "Вас пригласили. Создайте аккаунт, даже если регистрация в остальном закрыта.",
"alreadySignedIn": {
@@ -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,
+106
View File
@@ -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<void>;
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<Response>>();
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<void> {
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() }),
);
});
});
+5 -5
View File
@@ -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 }),
});
}