11 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 isdocs/superpowers/specs/2026-08-22-local-only-logout-visibility-design.md. GET /auth/configis 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/logoutroute, 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. - 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, andbrain/codebase/psd-dwh-transport.md.
Task 1: Propagate the local-auth capability and conditionally render logout
Files:
- Modify:
frontend/src/auth/AuthGate.test.tsx:10-17,63-68 - 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.modefrom the existinggetAuthConfig()call inAuthGate. - Produces:
interface AppShellProps {
canLogout: boolean;
}
export function AppShell({ canLogout }: AppShellProps): JSX.Element;
-
canLogoutis required at everyAppShellcall site. Production passesconfig?.mode === "local"; focused non-auth shell tests passfalse; existing local-auth shell tests passtrue. -
Step 1: Add RED tests for capability propagation and shell visibility
In frontend/src/auth/AuthGate.test.tsx, replace the AppShell mock with a capability-observing
mock:
vi.mock("../shell/AppShell", () => ({
AppShell: ({ canLogout }: { canLogout: boolean }) => (
<div data-testid="authenticated-shell" data-can-logout={String(canLogout)}>
Authenticated shell
{canLogout && (
<button type="button" onClick={() => { window.dispatchEvent(new Event("test-logout")); }}>
Log out
</button>
)}
</div>
),
}));
Extend the existing local authenticated-shell test so it proves the positive contract:
test("renders the authenticated shell from the safe /me DTO", async () => {
render(<AuthGate />);
const shell = await screen.findByTestId("authenticated-shell");
expect(shell).toHaveAttribute("data-can-logout", "true");
expect(screen.getByRole("button", { name: "Log out" })).toBeInTheDocument();
expect(screen.queryByRole("heading", { name: /sign in/i })).not.toBeInTheDocument();
});
Add an upstream regression beside it:
test("disables shell logout for trusted upstream authentication", async () => {
server.use(
http.get("/api/auth/config", () => HttpResponse.json({
mode: "upstream", localLogin: false, oidcLogin: false,
})),
http.get("/api/me", () => HttpResponse.json({
issuer: "portal",
subject: "portal-user",
displayName: "Portal user",
roles: ["user"],
permissions: ["session.use"],
isAdmin: false,
csrfToken: null,
session: null,
})),
);
render(<AuthGate />);
const shell = await screen.findByTestId("authenticated-shell");
expect(shell).toHaveAttribute("data-can-logout", "false");
expect(screen.queryByRole("button", { name: "Log out" })).not.toBeInTheDocument();
});
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/auth/AuthGate.test.tsx src/shell/AppShell.auth.test.tsx
Expected: FAIL. The local AuthGate test receives no canLogout prop, and the real AppShell
still renders Log out when the new negative test passes false.
- 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/auth/AuthGate.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
AppShellcaller 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/auth/AuthGate.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/auth/AuthGate.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.