# Local-Only Logout Visibility Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Show ThothII's `Log out` control only for standalone installations using local authentication, while keeping it absent from the Aritmolab server and every other non-local mode. **Architecture:** Reuse the already validated `GET /auth/config` response as the single runtime authority. `AuthGate` derives `canLogout` from `config.mode === "local"`, passes that explicit capability into `AppShell`, and `AppShell` conditionally renders only the existing button while leaving identity display and logout mechanics unchanged. **Tech Stack:** React 18, TypeScript 5.6, Vitest 2, React Testing Library, MSW, Vite 6. ## Global Constraints - Source baseline is `6475ea3`; the approved design is `docs/superpowers/specs/2026-08-22-local-only-logout-visibility-design.md`. - `GET /auth/config` is the sole authority. Do not add platform, hostname, URL, issuer, packaging, or build-time detection. - The exact visibility rule is `config.mode === "local"`; `upstream`, `oidc`, `none`, `mock`, an absent config, or an invalid config must never enable the control. - Keep the authenticated username or display name visible in every mode. - Preserve the existing `POST /auth/logout` route, logout coordinator, authentication-state cleanup, query cleanup, and session/transcript isolation behavior. - Do not change backend, Compose, native `tht`, deployment, or packaging files. - Do not add dependencies or change the English UI string `Log out`. - Visibility integration tests must render the real `App` and real `AppShell`; do not assert on props or elements exposed only by the mocked `AppShell` in `AuthGate.test.tsx`. - Use strict TDD: add the focused regression tests, observe the intended RED, implement the minimum change, then run focused and full GREEN gates. - Preserve and do not stage the pre-existing changes in `.superpowers/sdd/progress.md`, `brain/index.md`, and `brain/codebase/psd-dwh-transport.md`. --- ### Task 1: Propagate the local-auth capability and conditionally render logout **Files:** - Modify: `frontend/src/App.test.tsx:1-15` - Modify: `frontend/src/shell/AppShell.auth.test.tsx:11-28,45-89,90-153` - Modify: `frontend/src/auth/AuthGate.tsx:20-29,79-81` - Modify: `frontend/src/shell/AppShell.tsx:39-40,715-722` - Modify: `frontend/src/shell/AppShell.new-session.test.tsx:12-15` - Modify: `frontend/src/shell/AppShell.session-mgmt.test.tsx:22-26` - Modify: `frontend/src/shell/AppShell.session-target.test.tsx:12-15` **Interfaces:** - Consumes: validated `AuthPublicConfig.mode` from the existing `getAuthConfig()` call in `AuthGate`. - Produces: ```ts interface AppShellProps { canLogout: boolean; } export function AppShell({ canLogout }: AppShellProps): JSX.Element; ``` - `canLogout` is required at every `AppShell` call site. Production passes `config?.mode === "local"`; focused non-auth shell tests pass `false`; existing local-auth shell tests pass `true`. - [ ] **Step 1: Add RED tests for capability propagation and shell visibility** Replace `frontend/src/App.test.tsx` with a real `App` integration test that supplies all shell requests and varies only the public authentication mode: ```tsx import { render, screen } from "@testing-library/react"; import { http, HttpResponse } from "msw"; import { server } from "./test/msw"; import { App } from "./App"; function registerAuthenticatedShell(mode: "local" | "upstream") { server.use( 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 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(); }); ``` Keep the existing `AppShell` mock in `frontend/src/auth/AuthGate.test.tsx` unchanged. That suite continues to test gate state transitions; the new `App.test.tsx` cases own the real propagation and rendered-visibility contract. In `frontend/src/shell/AppShell.auth.test.tsx`, add a `canLogout` argument to the existing helper and make the keyed/direct local-auth renders explicit: ```tsx function renderShell(user: { subject: string; 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( , ); } function KeyedAuthenticatedShell() { const user = useAuthUser(); const generation = useAuthGeneration(); return user ? : null; } ``` Change the direct render in the stale-logout test to ``. Then add this negative shell test before the existing local logout test: ```tsx 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); }); ``` - [ ] **Step 2: Run the focused tests and capture RED** Run from `frontend/`: ```bash npx vitest run src/App.test.tsx src/shell/AppShell.auth.test.tsx ``` Expected: FAIL. The real `AppShell` still renders `Log out` in both the upstream integration and the new negative shell test. - [ ] **Step 3: Implement the minimal explicit capability flow** In `frontend/src/auth/AuthGate.tsx`, replace `AuthenticatedContent` with: ```tsx function AuthenticatedContent({ canLogout, onExpired, }: { canLogout: boolean; onExpired: () => void; }) { const user = useAuthUser(); const authGeneration = useAuthGeneration(); useEffect(() => { if (!user) onExpired(); }, [onExpired, user]); return user ? : null; } ``` Replace the authenticated branch with the closed local-only rule: ```tsx if (status === "authenticated") { return ( setStatus("login")} /> ); } ``` In `frontend/src/shell/AppShell.tsx`, add the required prop at the component boundary: ```tsx interface AppShellProps { canLogout: boolean; } export function AppShell({ canLogout }: AppShellProps) { const authenticatedUser = useAuthUser(); ``` Keep the identity row unchanged and wrap only the existing button: ```tsx {authenticatedUser && (
{authenticatedUser.displayName ?? authenticatedUser.subject} {canLogout && ( )}
)} ``` Update the three non-authentication shell test helpers so their intent is explicit and they do not render an irrelevant logout control: ```tsx // frontend/src/shell/AppShell.new-session.test.tsx return render( , ); // frontend/src/shell/AppShell.session-mgmt.test.tsx return render( , ); // frontend/src/shell/AppShell.session-target.test.tsx return render( , ); ``` Do not alter `signOut()`, `logoutUser()`, `frontend/src/api/auth.ts`, or any backend route. - [ ] **Step 4: Run the focused tests and confirm GREEN** Run from `frontend/`: ```bash npx vitest run src/App.test.tsx src/shell/AppShell.auth.test.tsx ``` Expected: both test files PASS, including the new local-positive and upstream-negative cases. - [ ] **Step 5: Prove every `AppShell` caller supplies the capability** Run from `frontend/`: ```bash npx tsc -b ``` Expected: exit code 0 with no TypeScript diagnostics. A missing `canLogout` at any production or test call site is a compile error. - [ ] **Step 6: Run complete frontend verification** Run from `frontend/`: ```bash npx vitest run npm run build ``` Expected: the complete Vitest suite passes; the build completes TypeScript project checking and Vite production bundling with exit code 0. - [ ] **Step 7: Review scope and commit the implementation** Run from the repository root: ```bash git diff --check git diff -- frontend/src/auth/AuthGate.tsx \ frontend/src/App.test.tsx \ frontend/src/shell/AppShell.tsx \ frontend/src/shell/AppShell.auth.test.tsx \ frontend/src/shell/AppShell.new-session.test.tsx \ frontend/src/shell/AppShell.session-mgmt.test.tsx \ frontend/src/shell/AppShell.session-target.test.tsx git status --short ``` Expected: no whitespace errors; the implementation diff contains only capability propagation, conditional button rendering, and the corresponding test updates. The pre-existing brain and SDD files remain unstaged. Commit only the implementation files: ```bash git add frontend/src/auth/AuthGate.tsx \ frontend/src/App.test.tsx \ frontend/src/shell/AppShell.tsx \ frontend/src/shell/AppShell.auth.test.tsx \ frontend/src/shell/AppShell.new-session.test.tsx \ frontend/src/shell/AppShell.session-mgmt.test.tsx \ frontend/src/shell/AppShell.session-target.test.tsx git commit -m "fix(frontend): hide logout outside local auth" ``` Expected: one commit containing only the seven listed frontend files. Do not stage or commit the pre-existing workspace changes.