fix(frontend): hide logout outside local auth

This commit is contained in:
User
2026-08-22 19:43:42 +02:00
parent 309bc44d4a
commit 9c47fb5f3d
7 changed files with 92 additions and 15 deletions
+40 -3
View File
@@ -3,13 +3,50 @@ import { http, HttpResponse } from "msw";
import { server } from "./test/msw"; import { server } from "./test/msw";
import { App } from "./App"; import { App } from "./App";
test("App renders the shell with create affordance after authentication", async () => { function registerAuthenticatedShell(mode: "local" | "upstream") {
server.use( 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/workspaces", () => HttpResponse.json([])),
http.get("/api/models", () => HttpResponse.json({ models: [] })), http.get("/api/models", () => HttpResponse.json({ models: [] })),
http.get("/api/sessions", () => HttpResponse.json([])), 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(<App />); render(<App />);
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(<App />);
expect(await screen.findByText("Portal user")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Log out" })).not.toBeInTheDocument();
}); });
+14 -3
View File
@@ -17,14 +17,20 @@ import { Button } from "../components/ui/button";
type GateStatus = "loading" | "login" | "authenticated" | "forbidden" | "unavailable"; type GateStatus = "loading" | "login" | "authenticated" | "forbidden" | "unavailable";
function AuthenticatedContent({ onExpired }: { onExpired: () => void }) { function AuthenticatedContent({
canLogout,
onExpired,
}: {
canLogout: boolean;
onExpired: () => void;
}) {
const user = useAuthUser(); const user = useAuthUser();
const authGeneration = useAuthGeneration(); const authGeneration = useAuthGeneration();
useEffect(() => { useEffect(() => {
if (!user) onExpired(); if (!user) onExpired();
}, [onExpired, user]); }, [onExpired, user]);
return user return user
? <AppShell key={`${user.issuer}:${user.subject}:${authGeneration}`} /> ? <AppShell key={`${user.issuer}:${user.subject}:${authGeneration}`} canLogout={canLogout} />
: null; : null;
} }
@@ -77,7 +83,12 @@ export function AuthGate() {
} }
if (status === "authenticated") { if (status === "authenticated") {
return <AuthenticatedContent onExpired={() => setStatus("login")} />; return (
<AuthenticatedContent
canLogout={config?.mode === "local"}
onExpired={() => setStatus("login")}
/>
);
} }
if (status === "unavailable") { if (status === "unavailable") {
+25 -4
View File
@@ -13,18 +13,20 @@ function renderShell(user: {
isAdmin: boolean; isAdmin: boolean;
roles: readonly ("user" | "admin")[]; roles: readonly ("user" | "admin")[];
permissions: readonly string[]; permissions: readonly string[];
}) { }, canLogout = true) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
setAuthState({ setAuthState({
issuer: "local", ...user, csrfToken: null, session: null, issuer: "local", ...user, csrfToken: null, session: null,
}); });
return render(<QueryClientProvider client={client}><AppShell /></QueryClientProvider>); return render(<QueryClientProvider client={client}><AppShell canLogout={canLogout} /></QueryClientProvider>);
} }
function KeyedAuthenticatedShell() { function KeyedAuthenticatedShell() {
const user = useAuthUser(); const user = useAuthUser();
const generation = useAuthGeneration(); const generation = useAuthGeneration();
return user ? <AppShell key={`${user.issuer}:${user.subject}:${generation}`} /> : null; return user
? <AppShell key={`${user.issuer}:${user.subject}:${generation}`} canLogout />
: null;
} }
beforeEach(() => { beforeEach(() => {
@@ -63,6 +65,25 @@ describe("authenticated shell permissions", () => {
expect(screen.getByRole("button", { name: "All sessions" })).toBeInTheDocument(); 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 () => { test("logout revokes the cookie session and clears in-memory auth", async () => {
const user = { const user = {
issuer: "local" as const, issuer: "local" as const,
@@ -112,7 +133,7 @@ describe("authenticated shell permissions", () => {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
setAuthState(userA); setAuthState(userA);
render(<QueryClientProvider client={client}><AppShell /></QueryClientProvider>); render(<QueryClientProvider client={client}><AppShell canLogout /></QueryClientProvider>);
await userEvent.click(screen.getByRole("button", { name: "Log out" })); await userEvent.click(screen.getByRole("button", { name: "Log out" }));
await started; await started;
@@ -11,7 +11,7 @@ import { workspacePreferences } from "../workspaces/preferences";
function renderShell() { function renderShell() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(<QueryClientProvider client={client}><AppShell /></QueryClientProvider>); return render(<QueryClientProvider client={client}><AppShell canLogout={false} /></QueryClientProvider>);
} }
beforeEach(() => { beforeEach(() => {
@@ -22,7 +22,7 @@ const adminUser: AuthenticatedUser = {
function wrap(user: AuthenticatedUser = regularUser) { function wrap(user: AuthenticatedUser = regularUser) {
if (user !== regularUser) setAuthState(user); if (user !== regularUser) setAuthState(user);
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(<QueryClientProvider client={client}><AppShell /></QueryClientProvider>); return render(<QueryClientProvider client={client}><AppShell canLogout={false} /></QueryClientProvider>);
} }
const LIST = [ const LIST = [
@@ -11,7 +11,7 @@ import { useSessionStore } from "../store/sessionStore";
function renderShell() { function renderShell() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(<QueryClientProvider client={client}><AppShell /></QueryClientProvider>); return render(<QueryClientProvider client={client}><AppShell canLogout={false} /></QueryClientProvider>);
} }
function deferred() { function deferred() {
+10 -2
View File
@@ -36,7 +36,11 @@ import { useEffect, useMemo, useRef, useState } from "react";
import type { CSSProperties } from "react"; import type { CSSProperties } from "react";
import { captureAuthOperation, isAuthOperationCurrent, StaleAuthOperationError, type AuthOperationGuard } from "../auth/authOperation"; 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 authenticatedUser = useAuthUser();
const [panelSession, setPanelSession] = useState<SessionSummary | null>(null); const [panelSession, setPanelSession] = useState<SessionSummary | null>(null);
const { const {
@@ -717,7 +721,11 @@ export function AppShell() {
<span className="min-w-0 truncate text-xs text-muted-foreground" title={authenticatedUser.displayName ?? authenticatedUser.subject}> <span className="min-w-0 truncate text-xs text-muted-foreground" title={authenticatedUser.displayName ?? authenticatedUser.subject}>
{authenticatedUser.displayName ?? authenticatedUser.subject} {authenticatedUser.displayName ?? authenticatedUser.subject}
</span> </span>
<Button variant="ghost" size="xs" onClick={() => { void signOut().catch(() => undefined); }}>Log out</Button> {canLogout && (
<Button variant="ghost" size="xs" onClick={() => { void signOut().catch(() => undefined); }}>
Log out
</Button>
)}
</div> </div>
)} )}
</div> </div>