docs: plan local-only logout visibility
This commit is contained in:
@@ -0,0 +1,337 @@
|
|||||||
|
# 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`.
|
||||||
|
- 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/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.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**
|
||||||
|
|
||||||
|
In `frontend/src/auth/AuthGate.test.tsx`, replace the `AppShell` mock with a capability-observing
|
||||||
|
mock:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
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:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
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:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
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:
|
||||||
|
|
||||||
|
```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(
|
||||||
|
<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:
|
||||||
|
|
||||||
|
```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/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:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
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:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
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:
|
||||||
|
|
||||||
|
```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 && (
|
||||||
|
<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:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// 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/`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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 `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/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:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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.
|
||||||
Reference in New Issue
Block a user