mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 22:26:13 +00:00
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:
1 parent
31f296a03e
commit
6d8f533deb
13 files changed
+123
-7
No files matched your search
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -529,6 +529,7 @@
|
||||
"noAccount": "계정이 없나요?",
|
||||
"hasAccount": "이미 계정이 있나요?",
|
||||
"loginFailed": "로그인 실패",
|
||||
"signOutFailed": "로그아웃하지 못했어요. 아직 로그인되어 있어요.",
|
||||
"registerFailed": "회원가입 실패",
|
||||
"invitedBanner": "초대를 받았어요. 회원가입이 닫혀 있어도 계정을 만들 수 있어요.",
|
||||
"alreadySignedIn": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -529,6 +529,7 @@
|
||||
"noAccount": "Нет аккаунта?",
|
||||
"hasAccount": "Уже есть аккаунт?",
|
||||
"loginFailed": "Не удалось войти",
|
||||
"signOutFailed": "Не удалось выйти. Вы по-прежнему в системе.",
|
||||
"registerFailed": "Не удалось зарегистрироваться",
|
||||
"invitedBanner": "Вас пригласили. Создайте аккаунт, даже если регистрация в остальном закрыта.",
|
||||
"alreadySignedIn": {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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() }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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 }),
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user