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/app/auth/register/__tests__/page.browser.test.tsx b/src/app/auth/register/__tests__/page.browser.test.tsx index 0182022db..be17d6e25 100644 --- a/src/app/auth/register/__tests__/page.browser.test.tsx +++ b/src/app/auth/register/__tests__/page.browser.test.tsx @@ -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(, { + 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"); + }); +}); diff --git a/src/app/auth/register/page.tsx b/src/app/auth/register/page.tsx index d88ba1c32..592917c88 100644 --- a/src/app/auth/register/page.tsx +++ b/src/app/auth/register/page.tsx @@ -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); } diff --git a/src/components/auth/__tests__/claim-profile.browser.test.tsx b/src/components/auth/__tests__/claim-profile.browser.test.tsx index 210081d0d..b4a80a369 100644 --- a/src/components/auth/__tests__/claim-profile.browser.test.tsx +++ b/src/components/auth/__tests__/claim-profile.browser.test.tsx @@ -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 { @@ -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( + , + { + 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(""); + }); +}); diff --git a/src/components/auth/claim-profile.tsx b/src/components/auth/claim-profile.tsx index d15a6db16..54120d064 100644 --- a/src/components/auth/claim-profile.tsx +++ b/src/components/auth/claim-profile.tsx @@ -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"); }, },