Files
ThothII/docs/superpowers/plans/2026-08-22-local-only-logout-visibility.md
T

12 KiB

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:
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:

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(<App />);

  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();
});

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:

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(
    <QueryClientProvider client={client}>
      <AppShell canLogout={canLogout} />
    </QueryClientProvider>,
  );
}

function KeyedAuthenticatedShell() {
  const user = useAuthUser();
  const generation = useAuthGeneration();
  return user
    ? <AppShell key={`${user.issuer}:${user.subject}:${generation}`} canLogout />
    : null;
}

Change the direct render in the stale-logout test to <AppShell canLogout />. Then add this negative shell test before the existing local logout test:

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/:

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:

function AuthenticatedContent({
  canLogout,
  onExpired,
}: {
  canLogout: boolean;
  onExpired: () => void;
}) {
  const user = useAuthUser();
  const authGeneration = useAuthGeneration();
  useEffect(() => {
    if (!user) onExpired();
  }, [onExpired, user]);
  return user
    ? <AppShell key={`${user.issuer}:${user.subject}:${authGeneration}`} canLogout={canLogout} />
    : null;
}

Replace the authenticated branch with the closed local-only rule:

if (status === "authenticated") {
  return (
    <AuthenticatedContent
      canLogout={config?.mode === "local"}
      onExpired={() => setStatus("login")}
    />
  );
}

In frontend/src/shell/AppShell.tsx, add the required prop at the component boundary:

interface AppShellProps {
  canLogout: boolean;
}

export function AppShell({ canLogout }: AppShellProps) {
  const authenticatedUser = useAuthUser();

Keep the identity row unchanged and wrap only the existing button:

{authenticatedUser && (
  <div className="mt-4 flex items-center justify-between gap-2 border-t border-border/70 pt-3 text-left">
    <span className="min-w-0 truncate text-xs text-muted-foreground" title={authenticatedUser.displayName ?? authenticatedUser.subject}>
      {authenticatedUser.displayName ?? authenticatedUser.subject}
    </span>
    {canLogout && (
      <Button variant="ghost" size="xs" onClick={() => { void signOut().catch(() => undefined); }}>
        Log out
      </Button>
    )}
  </div>
)}

Update the three non-authentication shell test helpers so their intent is explicit and they do not render an irrelevant logout control:

// frontend/src/shell/AppShell.new-session.test.tsx
return render(
  <QueryClientProvider client={client}>
    <AppShell canLogout={false} />
  </QueryClientProvider>,
);

// frontend/src/shell/AppShell.session-mgmt.test.tsx
return render(
  <QueryClientProvider client={client}>
    <AppShell canLogout={false} />
  </QueryClientProvider>,
);

// frontend/src/shell/AppShell.session-target.test.tsx
return render(
  <QueryClientProvider client={client}>
    <AppShell canLogout={false} />
  </QueryClientProvider>,
);

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/:

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/:

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/:

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:

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:

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.