mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 22:26:13 +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
@@ -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": {
|
||||
|
||||
@@ -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