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,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 }),
});
}