From 9c47fb5f3d7399fdf4ac37e97525ea56d6a50b39 Mon Sep 17 00:00:00 2001 From: User Date: Sat, 22 Aug 2026 19:43:42 +0200 Subject: [PATCH] fix(frontend): hide logout outside local auth --- frontend/src/App.test.tsx | 43 +++++++++++++++++-- frontend/src/auth/AuthGate.tsx | 17 ++++++-- frontend/src/shell/AppShell.auth.test.tsx | 29 +++++++++++-- .../src/shell/AppShell.new-session.test.tsx | 2 +- .../src/shell/AppShell.session-mgmt.test.tsx | 2 +- .../shell/AppShell.session-target.test.tsx | 2 +- frontend/src/shell/AppShell.tsx | 12 +++++- 7 files changed, 92 insertions(+), 15 deletions(-) diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index b9b03201..7ac276a7 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -3,13 +3,50 @@ import { http, HttpResponse } from "msw"; import { server } from "./test/msw"; import { App } from "./App"; -test("App renders the shell with create affordance after authentication", async () => { +function registerAuthenticatedShell(mode: "local" | "upstream") { server.use( - http.get("/api/settings", () => HttpResponse.json({ workspace: "psd", provider: "zai", model: "glm-5.2", thinking: "medium" })), + http.get("/api/auth/config", () => HttpResponse.json({ + mode, + localLogin: mode === "local", + oidcLogin: false, + })), + http.get("/api/me", () => HttpResponse.json({ + issuer: mode === "local" ? "local" : "portal", + subject: "portal-user", + displayName: "Portal user", + roles: ["user"], + permissions: ["session.use"], + isAdmin: false, + csrfToken: mode === "local" ? "c".repeat(43) : null, + session: null, + })), + http.get("/api/settings", () => HttpResponse.json({ + workspace: "psd", provider: "zai", model: "glm-5.2", thinking: "medium", + })), http.get("/api/workspaces", () => HttpResponse.json([])), http.get("/api/models", () => HttpResponse.json({ models: [] })), http.get("/api/sessions", () => HttpResponse.json([])), + http.get("/api/workspace-registry/status", () => HttpResponse.json({ + branch: "main", ahead: 0, behind: 0, degraded: false, + })), ); +} + +test("local authentication renders the standalone logout control", async () => { + registerAuthenticatedShell("local"); + render(); - expect(await screen.findByRole("button", { name: /new/i })).toBeInTheDocument(); + + expect(await screen.findByRole("button", { name: "New session" })).toBeInTheDocument(); + expect(screen.getByText("Portal user")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Log out" })).toBeInTheDocument(); +}); + +test("trusted upstream authentication keeps identity but omits ThothII logout", async () => { + registerAuthenticatedShell("upstream"); + + render(); + + expect(await screen.findByText("Portal user")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Log out" })).not.toBeInTheDocument(); }); diff --git a/frontend/src/auth/AuthGate.tsx b/frontend/src/auth/AuthGate.tsx index 19e25d86..a8469c17 100644 --- a/frontend/src/auth/AuthGate.tsx +++ b/frontend/src/auth/AuthGate.tsx @@ -17,14 +17,20 @@ import { Button } from "../components/ui/button"; type GateStatus = "loading" | "login" | "authenticated" | "forbidden" | "unavailable"; -function AuthenticatedContent({ onExpired }: { onExpired: () => void }) { +function AuthenticatedContent({ + canLogout, + onExpired, +}: { + canLogout: boolean; + onExpired: () => void; +}) { const user = useAuthUser(); const authGeneration = useAuthGeneration(); useEffect(() => { if (!user) onExpired(); }, [onExpired, user]); return user - ? + ? : null; } @@ -77,7 +83,12 @@ export function AuthGate() { } if (status === "authenticated") { - return setStatus("login")} />; + return ( + setStatus("login")} + /> + ); } if (status === "unavailable") { diff --git a/frontend/src/shell/AppShell.auth.test.tsx b/frontend/src/shell/AppShell.auth.test.tsx index 3a13d381..16d5a8df 100644 --- a/frontend/src/shell/AppShell.auth.test.tsx +++ b/frontend/src/shell/AppShell.auth.test.tsx @@ -13,18 +13,20 @@ function renderShell(user: { isAdmin: boolean; roles: readonly ("user" | "admin")[]; permissions: readonly string[]; -}) { +}, canLogout = true) { const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); setAuthState({ issuer: "local", ...user, csrfToken: null, session: null, }); - return render(); + return render(); } function KeyedAuthenticatedShell() { const user = useAuthUser(); const generation = useAuthGeneration(); - return user ? : null; + return user + ? + : null; } beforeEach(() => { @@ -63,6 +65,25 @@ describe("authenticated shell permissions", () => { expect(screen.getByRole("button", { name: "All sessions" })).toBeInTheDocument(); }); + test("hides logout outside local authentication while preserving the identity", async () => { + let logoutCalls = 0; + server.use(http.post("/api/auth/logout", () => { + logoutCalls += 1; + return new HttpResponse(null, { status: 204 }); + })); + + renderShell({ + subject: "portal-user", + isAdmin: false, + roles: ["user"], + permissions: ["session.use"], + }, false); + + expect(await screen.findByText("portal-user")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Log out" })).not.toBeInTheDocument(); + expect(logoutCalls).toBe(0); + }); + test("logout revokes the cookie session and clears in-memory auth", async () => { const user = { issuer: "local" as const, @@ -112,7 +133,7 @@ describe("authenticated shell permissions", () => { const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); setAuthState(userA); - render(); + render(); await userEvent.click(screen.getByRole("button", { name: "Log out" })); await started; diff --git a/frontend/src/shell/AppShell.new-session.test.tsx b/frontend/src/shell/AppShell.new-session.test.tsx index e570ffd4..99ed7071 100644 --- a/frontend/src/shell/AppShell.new-session.test.tsx +++ b/frontend/src/shell/AppShell.new-session.test.tsx @@ -11,7 +11,7 @@ import { workspacePreferences } from "../workspaces/preferences"; function renderShell() { const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); - return render(); + return render(); } beforeEach(() => { diff --git a/frontend/src/shell/AppShell.session-mgmt.test.tsx b/frontend/src/shell/AppShell.session-mgmt.test.tsx index 3b2b3550..13af31d8 100644 --- a/frontend/src/shell/AppShell.session-mgmt.test.tsx +++ b/frontend/src/shell/AppShell.session-mgmt.test.tsx @@ -22,7 +22,7 @@ const adminUser: AuthenticatedUser = { function wrap(user: AuthenticatedUser = regularUser) { if (user !== regularUser) setAuthState(user); const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); - return render(); + return render(); } const LIST = [ diff --git a/frontend/src/shell/AppShell.session-target.test.tsx b/frontend/src/shell/AppShell.session-target.test.tsx index e4cba8f3..6a7ff664 100644 --- a/frontend/src/shell/AppShell.session-target.test.tsx +++ b/frontend/src/shell/AppShell.session-target.test.tsx @@ -11,7 +11,7 @@ import { useSessionStore } from "../store/sessionStore"; function renderShell() { const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); - return render(); + return render(); } function deferred() { diff --git a/frontend/src/shell/AppShell.tsx b/frontend/src/shell/AppShell.tsx index 7041678e..d7097cd8 100644 --- a/frontend/src/shell/AppShell.tsx +++ b/frontend/src/shell/AppShell.tsx @@ -36,7 +36,11 @@ import { useEffect, useMemo, useRef, useState } from "react"; import type { CSSProperties } from "react"; import { captureAuthOperation, isAuthOperationCurrent, StaleAuthOperationError, type AuthOperationGuard } from "../auth/authOperation"; -export function AppShell() { +interface AppShellProps { + canLogout: boolean; +} + +export function AppShell({ canLogout }: AppShellProps) { const authenticatedUser = useAuthUser(); const [panelSession, setPanelSession] = useState(null); const { @@ -717,7 +721,11 @@ export function AppShell() { {authenticatedUser.displayName ?? authenticatedUser.subject} - + {canLogout && ( + + )} )}