fix(auth): keep the page when a sign-out from an auth page is refused

The sign-out buttons on the registration and profile take-over pages
took every HTTP answer as success. A refused sign-out (the demo's 403,
a 429, a 5xx) wiped the client caches and reloaded the page while the
session stayed valid; on the take-over page a network failure was an
unhandled rejection with no message at all.

Both go through apiFetch now, which throws on a non-2xx answer, and say
that the person is still signed in: inline on the registration page, as
a toast on the take-over page. The message is the one the account
menu's sign-out uses.
This commit is contained in:
Marc-André Bombeck committed 2026-11-11 21:18:46 +01:00
1 parent 8fdc238981
commit a4766824ef
14 files changed
+73 -9

No files matched your search

@@ -46,3 +46,27 @@ describe("registration page", () => {
);
});
});
describe("registration page, already signed in", () => {
it("keeps the page and says so when the server refuses the sign-out", async () => {
const api = stubFetch().on("POST", "/api/auth/logout", {
status: 403,
error: "Demo mode: modifications are disabled",
meta: { demo: true, errorCode: "demo.readOnly" },
});
window.history.replaceState(null, "", "/auth/register?invite=hlv_x");
const { screen, queryClient } = await renderWithApp(<RegisterPage />, {
user: { username: "signed-in" },
});
await screen.getByTestId("register-sign-out").click();
await expect
.element(screen.getByText("Could not sign out. You are still signed in."))
.toBeVisible();
expect(api.calls("POST", "/api/auth/logout")).toHaveLength(1);
// Nothing was wiped and nobody was sent anywhere: the session is alive.
expect(queryClient.getQueryData(queryKeys.authMe())).toBeDefined();
expect(window.location.search).toBe("?invite=hlv_x");
});
});
+3 -3
View File
@@ -12,7 +12,7 @@ import { Label } from "@/components/ui/label";
import { PasswordStrength } from "@/components/ui/password-strength";
import { useTranslations } from "@/lib/i18n/context";
import { detectBrowserTimezone } from "@/lib/tz/format";
import { ApiError, apiFetchRaw, apiPost } from "@/lib/api/api-fetch";
import { ApiError, apiFetch, apiPost } from "@/lib/api/api-fetch";
import { clearCachesForSessionEnd, useAuth } from "@/hooks/use-auth";
export default function RegisterPage() {
@@ -90,7 +90,7 @@ export default function RegisterPage() {
setError(null);
setSigningOut(true);
try {
await apiFetchRaw("/api/auth/logout", { method: "POST" });
await apiFetch("/api/auth/logout", { method: "POST" });
// Same session-end wipe the shell and the menu do: the QueryClient
// outlives the navigation on this SPA and the health-data families are
// not user-scoped, so nothing may survive into the next account.
@@ -105,7 +105,7 @@ export default function RegisterPage() {
);
router.refresh();
} catch {
setError(t("common.networkError"));
setError(t("auth.signOutFailed"));
} finally {
setSigningOut(false);
}
@@ -21,11 +21,13 @@ import { recordHistory } from "../../../../tests/browser/url";
const clearOffline = vi.hoisted(() => vi.fn(async () => {}));
const loadDocument = vi.hoisted(() => vi.fn());
const showErrorToast = vi.hoisted(() => vi.fn());
vi.mock("@/lib/pwa/query-persister", () => ({
clearOfflineCachesForSessionEnd: clearOffline,
}));
vi.mock("@/lib/navigation/load-document", () => ({ loadDocument }));
vi.mock("@/lib/errors/error-toast", () => ({ showErrorToast }));
import { ClaimProfile } from "../claim-profile";
@@ -57,6 +59,7 @@ beforeEach(() => {
afterEach(() => {
clearOffline.mockClear();
loadDocument.mockClear();
showErrorToast.mockClear();
});
async function fill(selector: string, value: string): Promise<void> {
@@ -112,3 +115,29 @@ describe("ClaimProfile", () => {
);
});
});
describe("ClaimProfile, already signed in", () => {
it("keeps the session's page and says so when the server refuses the sign-out", async () => {
api.on("POST", "/api/auth/logout", {
status: 403,
error: "Demo mode: modifications are disabled",
meta: { demo: true, errorCode: "demo.readOnly" },
});
const { queryClient } = await renderWithApp(
<ClaimProfile token={TOKEN} />,
{
user: { username: "signed-in" },
},
);
await click("claim-sign-out");
await expect.poll(() => showErrorToast.mock.calls.length).toBe(1);
expect(showErrorToast.mock.calls[0][0]).toBe(
"Could not sign out. You are still signed in.",
);
expect(clearOffline).not.toHaveBeenCalled();
expect(queryClient.getQueryData(queryKeys.authMe())).toBeDefined();
expect(window.location.search).toBe("");
});
});
+7 -6
View File
@@ -14,7 +14,8 @@ import { Label } from "@/components/ui/label";
import { Logo } from "@/components/ui/logo";
import { PasswordStrength } from "@/components/ui/password-strength";
import { clearCachesForSessionEnd, useAuth } from "@/hooks/use-auth";
import { ApiError, apiFetchRaw } from "@/lib/api/api-fetch";
import { ApiError, apiFetch } from "@/lib/api/api-fetch";
import { showErrorToast } from "@/lib/errors/error-toast";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { loadDocument } from "@/lib/navigation/load-document";
import { clearOfflineCachesForSessionEnd } from "@/lib/pwa/query-persister";
@@ -78,7 +79,7 @@ export function ClaimProfile({
async function signOut() {
setSigningOut(true);
try {
await apiFetchRaw("/api/auth/logout", { method: "POST" });
await apiFetch("/api/auth/logout", { method: "POST" });
clearCachesForSessionEnd(queryClient);
// Back to this page with the link intact: the token only rides the URL.
router.replace(
@@ -87,6 +88,8 @@ export function ClaimProfile({
: "/auth/claim",
);
router.refresh();
} catch (failure) {
showErrorToast(t("auth.signOutFailed"), { cause: failure });
} finally {
setSigningOut(false);
}
@@ -98,8 +101,7 @@ export function ClaimProfile({
{ token, username },
{
onSuccess: async ({ redirectTo }) => {
// Only the path and query of the server's answer, so a response
// could never send the browser off this origin.
// Path and query only, so an answer can never leave this origin.
const next = new URL(redirectTo, window.location.origin);
if (clientTransport) {
next.searchParams.set("client_proto", clientTransport.protocol);
@@ -120,8 +122,7 @@ export function ClaimProfile({
{ token, username, email, password },
{
onSuccess: () => {
// As after any sign-in: nothing cached before belongs to this account.
queryClient.clear();
queryClient.clear(); // as after any sign-in
router.replace("/onboarding/handover");
},
},