From af3f1a5367f215680b5592c8e118812465e30f5c Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Mon, 8 Jun 2026 15:06:50 +0200 Subject: [PATCH] feat(web): return-to-destination on auth redirect; logout pending state (#48) --- web/src/auth/require-auth.test.tsx | 13 +++++++++---- web/src/auth/require-auth.tsx | 8 ++++++-- web/src/shell/user-menu.test.tsx | 21 ++++++++++++++++++++- web/src/shell/user-menu.tsx | 4 +++- 4 files changed, 38 insertions(+), 8 deletions(-) diff --git a/web/src/auth/require-auth.test.tsx b/web/src/auth/require-auth.test.tsx index 643e9cc..d0faa1a 100644 --- a/web/src/auth/require-auth.test.tsx +++ b/web/src/auth/require-auth.test.tsx @@ -1,16 +1,21 @@ import { screen, waitFor } from "@testing-library/react"; import { http, HttpResponse } from "msw"; import { expect, test } from "vitest"; -import { Route, Routes } from "react-router-dom"; +import { Route, Routes, useLocation } from "react-router-dom"; import { server } from "../test/server"; import { renderApp } from "../test/render"; import { RequireAuth } from "./require-auth"; +function LoginStub() { + const location = useLocation(); + return
login page {location.search}
; +} + function tree() { return ( - login page} /> + } /> }> secret objects} /> @@ -23,8 +28,8 @@ test("renders children when authenticated", async () => { expect(await screen.findByText("secret objects")).toBeInTheDocument(); }); -test("redirects to /login when unauthenticated", async () => { +test("redirects unauthenticated users to /login carrying the attempted path", async () => { server.use(http.get("/api/admin/me", () => new HttpResponse(null, { status: 401 }))); renderApp(tree(), { route: "/objects" }); - await waitFor(() => expect(screen.getByText("login page")).toBeInTheDocument()); + await waitFor(() => expect(screen.getByText(/from=%2Fobjects/)).toBeInTheDocument()); }); diff --git a/web/src/auth/require-auth.tsx b/web/src/auth/require-auth.tsx index 23876f3..7adb927 100644 --- a/web/src/auth/require-auth.tsx +++ b/web/src/auth/require-auth.tsx @@ -1,14 +1,18 @@ -import { Navigate, Outlet } from "react-router-dom"; +import { Navigate, Outlet, useLocation } from "react-router-dom"; import { useMe } from "../api/queries"; import { AppShellSkeleton } from "@/components/ui/skeletons"; export function RequireAuth() { const { data: user, isLoading } = useMe(); + const location = useLocation(); if (isLoading) return ; - if (!user) return ; + if (!user) { + const from = encodeURIComponent(location.pathname + location.search); + return ; + } return ; } diff --git a/web/src/shell/user-menu.test.tsx b/web/src/shell/user-menu.test.tsx index 70085b9..784c478 100644 --- a/web/src/shell/user-menu.test.tsx +++ b/web/src/shell/user-menu.test.tsx @@ -1,7 +1,7 @@ import { expect, test } from "vitest"; import { screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { http, HttpResponse } from "msw"; +import { delay, http, HttpResponse } from "msw"; import { server } from "../test/server"; import { renderApp } from "../test/render"; import { UserMenu } from "./user-menu"; @@ -33,3 +33,22 @@ test("opens the menu showing email + role and signs out", async () => { await userEvent.click(signOut); await waitFor(() => expect(loggedOut).toBe(true)); }); + +test("shows a pending state on Sign out while logging out", async () => { + server.use( + http.post("/api/admin/logout", async () => { + await delay(50); + return new HttpResponse(null, { status: 204 }); + }), + ); + + renderApp(); + + const trigger = await screen.findByRole("button", { name: /editor@example.com/ }); + await userEvent.click(trigger); + + const menu = within(document.body); + await userEvent.click(await menu.findByText("Sign out")); + + expect(await menu.findByText(/signing out/i)).toBeInTheDocument(); +}); diff --git a/web/src/shell/user-menu.tsx b/web/src/shell/user-menu.tsx index a8779be..643feb1 100644 --- a/web/src/shell/user-menu.tsx +++ b/web/src/shell/user-menu.tsx @@ -35,7 +35,9 @@ export function UserMenu() {
{me.role}
- {t("auth.signOut")} + + {logout.isPending ? t("auth.signingOut") : t("auth.signOut")} + );