fix(frontend): hide logout outside local auth
This commit is contained in:
@@ -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();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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") {
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user