mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 06:32:03 +00:00
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:
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");
|
||||
});
|
||||
});
|
||||
@@ -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("");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
},
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user