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