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 && (
+
+ )}
)}