From 58e0884df19a742105032c7e0000387a12c7b3dc Mon Sep 17 00:00:00 2001 From: User Date: Thu, 16 Jul 2026 18:46:18 +0200 Subject: [PATCH] feat(frontend): expose owned session scopes --- .superpowers/sdd/task-6-report.md | 51 +++++++++++ frontend/src/api/sessions.test.ts | 30 ++++++- frontend/src/api/sessions.ts | 9 +- frontend/src/api/types.ts | 9 ++ .../src/shell/AppShell.session-mgmt.test.tsx | 88 +++++++++++++++++++ frontend/src/shell/AppShell.tsx | 66 ++++++++++++-- frontend/src/shell/NavSessions.test.tsx | 13 +++ frontend/src/shell/NavSessions.tsx | 24 +++-- 8 files changed, 270 insertions(+), 20 deletions(-) create mode 100644 .superpowers/sdd/task-6-report.md diff --git a/.superpowers/sdd/task-6-report.md b/.superpowers/sdd/task-6-report.md new file mode 100644 index 00000000..9b5e18bc --- /dev/null +++ b/.superpowers/sdd/task-6-report.md @@ -0,0 +1,51 @@ +# Task 6 — Frontend identity and administrator UX report + +## RED + +- Added API tests for the `/me` principal call and `mine`/`all` session-list scopes. +- Added component tests for regular-user scope, admin scope switching, owner labels, + administrator banner, foreign-owner delete confirmation, and foreign-owner archive + confirmation. +- Initial focused run: 7 expected failures (missing `getMe`, missing scope query, + missing owner label/admin controls, and missing foreign-action confirmation). +- The archive-confirmation regression was also run separately before its implementation + and failed because `window.confirm` was not called. + +## GREEN + +- `npx vitest run src/api/sessions.test.ts src/shell/NavSessions.test.tsx src/shell/AppShell.session-mgmt.test.tsx` + — passed (47 tests before the archive follow-up; the focused archive regression then passed). +- `npm test` — passed: 44 files / 305 tests. +- `npx tsc -b` — passed. +- `npm run build` — passed. +- `git diff --check` — passed. +- `npm run e2e` reached Playwright but could not run: the environment has no Chromium + executable at Playwright's configured cache path. No application test failure was reported. + +## Files changed + +- `frontend/src/api/types.ts`: typed principal and session scope contracts. +- `frontend/src/api/sessions.ts`: typed `/me` API call; scoped listing defaults to `mine`. +- `frontend/src/shell/AppShell.tsx`: identity query, admin-only session scope selector and + banner, owner-aware destructive action confirmations. +- `frontend/src/shell/NavSessions.tsx`: owner labels in the all-sessions view. +- `frontend/src/api/sessions.test.ts`, `frontend/src/shell/NavSessions.test.tsx`, and + `frontend/src/shell/AppShell.session-mgmt.test.tsx`: contract and UX coverage. + +## Self-review + +- Regular users remain fail-closed on `mine`; no administrator control renders without + `principal.isAdmin`. +- The all-sessions view includes owner labels (including `Unknown` for legacy records). +- Delete confirmation preserves the pre-existing select-all behavior and adds confirmation + for foreign/unknown owners. Foreign archive now also requires an explicit browser + confirmation; existing Stop & save already has its confirmation dialog. +- A read-only review found no critical, important, or minor issues. The archive guard was + added after that review in response to the requirement to cover every destructive rail + action, and has its own RED/GREEN regression plus the final full verification above. + +## Concerns + +- E2E remains environment-blocked until the Playwright Chromium browser is installed. +- Existing Vitest runs emit pre-existing MSW unmatched-request and dialog-ref warnings; all + assertions pass and this task does not modify those shared test/UI primitives. diff --git a/frontend/src/api/sessions.test.ts b/frontend/src/api/sessions.test.ts index 58664284..c3491feb 100644 --- a/frontend/src/api/sessions.test.ts +++ b/frontend/src/api/sessions.test.ts @@ -1,6 +1,6 @@ import { http, HttpResponse } from "msw"; import { server } from "../test/msw"; -import { createSession, listSessions, prewarmRuntime, resumeSession } from "./sessions"; +import { createSession, getMe, listSessions, prewarmRuntime, resumeSession } from "./sessions"; import { renameSession, setSessionGroup, archiveSession, unarchiveSession, deleteSession, getSessionDocuments, @@ -30,10 +30,34 @@ test.each([202, 204])("prewarmRuntime accepts a body-less %s response", async (s expect(called).toBe(true); }); -test("listSessions GETs the array", async () => { - server.use(http.get("http://localhost:8787/sessions", () => HttpResponse.json([{ id: "s1", status: "open", question: "q", summary: null, created_at: "t", updated_at: null, author: null }]))); +test("listSessions defaults to the current user's scope", async () => { + let scope: string | null = null; + server.use(http.get("http://localhost:8787/sessions", ({ request }) => { + scope = new URL(request.url).searchParams.get("scope"); + return HttpResponse.json([{ id: "s1", status: "open", question: "q", summary: null, created_at: "t", updated_at: null, author: null }]); + })); const rows = await listSessions(); expect(rows[0].id).toBe("s1"); + expect(scope).toBe("mine"); +}); + +test("listSessions requests the selected administrator scope", async () => { + let scope: string | null = null; + server.use(http.get("http://localhost:8787/sessions", ({ request }) => { + scope = new URL(request.url).searchParams.get("scope"); + return HttpResponse.json([]); + })); + await listSessions("all"); + expect(scope).toBe("all"); +}); + +test("getMe fetches the typed authenticated principal", async () => { + server.use(http.get("http://localhost:8787/me", () => + HttpResponse.json({ issuer: "portal", subject: "42", displayName: "Alice", isAdmin: true }), + )); + await expect(getMe()).resolves.toEqual({ + issuer: "portal", subject: "42", displayName: "Alice", isAdmin: true, + }); }); test("resumeSession returns the typed runtime disposition", async () => { diff --git a/frontend/src/api/sessions.ts b/frontend/src/api/sessions.ts index 53e3cff9..eabb2874 100644 --- a/frontend/src/api/sessions.ts +++ b/frontend/src/api/sessions.ts @@ -1,5 +1,7 @@ import { apiFetch } from "./client"; -import type { ResumeSessionResult, SessionSummary, SessionDocument, UiResponse } from "./types"; +import type { + Principal, ResumeSessionResult, SessionScope, SessionSummary, SessionDocument, UiResponse, +} from "./types"; export const createSession = (i: { question: string; name?: string }) => apiFetch<{ id: string }>("/sessions", { method: "POST", body: JSON.stringify(i) }); @@ -8,7 +10,10 @@ export const createSession = (i: { question: string; name?: string }) => export const prewarmRuntime = () => apiFetch("/runtime/prewarm", { method: "POST" }); -export const listSessions = () => apiFetch("/sessions"); +export const getMe = () => apiFetch("/me"); + +export const listSessions = (scope: SessionScope = "mine") => + apiFetch(`/sessions?scope=${scope}`); // eslint-disable-next-line @typescript-eslint/no-explicit-any export const getSession = (id: string) => apiFetch(`/sessions/${id}`); diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index e1d9a6c8..872e7543 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -104,6 +104,15 @@ export interface SessionSummary { archived: boolean; } +export type SessionScope = "mine" | "all"; + +export interface Principal { + issuer: string; + subject: string; + displayName?: string; + isAdmin: boolean; +} + export interface ResumeSessionResult { id: string; alreadyActive: boolean; diff --git a/frontend/src/shell/AppShell.session-mgmt.test.tsx b/frontend/src/shell/AppShell.session-mgmt.test.tsx index e841a229..00140cc3 100644 --- a/frontend/src/shell/AppShell.session-mgmt.test.tsx +++ b/frontend/src/shell/AppShell.session-mgmt.test.tsx @@ -54,6 +54,9 @@ beforeEach(() => { window.matchMedia = vi.fn().mockReturnValue({ matches: true, addEventListener: vi.fn(), removeEventListener: vi.fn() }); useSessionStore.getState().resetSession(); server.use( + http.get("http://localhost:8787/me", () => + HttpResponse.json({ issuer: "portal", subject: "alice", displayName: "Alice", isAdmin: false }), + ), http.get("http://localhost:8787/sessions", () => HttpResponse.json(LIST)), http.get("http://localhost:8787/sessions/:id/documents", () => HttpResponse.json([ { phase: "—", key: "question", title: "Domanda originale", format: "text", content: "Attiva uno" }, @@ -62,6 +65,91 @@ beforeEach(() => { ); }); +test("regular users load only their sessions and never see administrator controls", async () => { + let scope: string | null = null; + server.use(http.get("http://localhost:8787/sessions", ({ request }) => { + scope = new URL(request.url).searchParams.get("scope"); + return HttpResponse.json(LIST); + })); + wrap(); + await screen.findByText("Attiva uno"); + expect(scope).toBe("mine"); + expect(screen.queryByRole("button", { name: "All sessions" })).not.toBeInTheDocument(); + expect(screen.queryByText(/administrator view/i)).not.toBeInTheDocument(); +}); + +test("administrators can explicitly switch to all sessions and see owners", async () => { + let scope = ""; + server.use( + http.get("http://localhost:8787/me", () => + HttpResponse.json({ issuer: "portal", subject: "alice", displayName: "Alice", isAdmin: true }), + ), + http.get("http://localhost:8787/sessions", ({ request }) => { + scope = new URL(request.url).searchParams.get("scope") ?? ""; + return HttpResponse.json([ + { ...LIST[0], author: "Alice" }, + { ...LIST[1], id: "s3", question: "Another owner's session", archived: false, author: "Bob" }, + ]); + }), + ); + wrap(); + await screen.findByRole("button", { name: "All sessions" }); + await userEvent.click(screen.getByRole("button", { name: "All sessions" })); + await waitFor(() => expect(scope).toBe("all")); + expect(await screen.findByText("Administrator view: all sessions")).toBeInTheDocument(); + expect(screen.getByText("Owner: Bob")).toBeInTheDocument(); +}); + +test("administrator confirms before deleting another owner's session", async () => { + let deletes = 0; + server.use( + http.get("http://localhost:8787/me", () => + HttpResponse.json({ issuer: "portal", subject: "alice", displayName: "Alice", isAdmin: true }), + ), + http.get("http://localhost:8787/sessions", () => HttpResponse.json([ + { ...LIST[0], author: "Bob" }, + ])), + http.delete("http://localhost:8787/sessions/:id", () => { + deletes += 1; + return new HttpResponse(null, { status: 204 }); + }), + ); + wrap(); + await userEvent.click(await screen.findByRole("button", { name: "All sessions" })); + await screen.findByText("Owner: Bob"); + await userEvent.click(screen.getByRole("checkbox", { name: "Select Attiva uno" })); + await userEvent.click(screen.getByRole("button", { name: "Delete 1 selected sessions" })); + expect(deletes).toBe(0); + expect(await screen.findByRole("heading", { name: "Delete permanently" })).toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: "Delete" })); + await waitFor(() => expect(deletes).toBe(1)); +}); + +test("administrator confirms before archiving another owner's session", async () => { + let archives = 0; + const confirm = vi.spyOn(window, "confirm").mockReturnValue(false); + server.use( + http.get("http://localhost:8787/me", () => + HttpResponse.json({ issuer: "portal", subject: "alice", displayName: "Alice", isAdmin: true }), + ), + http.get("http://localhost:8787/sessions", () => HttpResponse.json([ + { ...LIST[0], author: "Bob" }, + ])), + http.post("http://localhost:8787/sessions/:id/archive", () => { + archives += 1; + return new HttpResponse(null, { status: 204 }); + }), + ); + wrap(); + await userEvent.click(await screen.findByRole("button", { name: "All sessions" })); + await screen.findByText("Owner: Bob"); + await userEvent.click(screen.getByRole("button", { name: "Session actions" })); + await userEvent.click(await screen.findByText("Archive")); + expect(confirm).toHaveBeenCalledWith("Archive Bob's session?"); + expect(archives).toBe(0); + confirm.mockRestore(); +}); + test("active list shows group header and hides archived sessions", async () => { wrap(); expect(await screen.findByText("Attiva uno")).toBeInTheDocument(); diff --git a/frontend/src/shell/AppShell.tsx b/frontend/src/shell/AppShell.tsx index a3cc3941..9caeceb7 100644 --- a/frontend/src/shell/AppShell.tsx +++ b/frontend/src/shell/AppShell.tsx @@ -18,10 +18,10 @@ import { Checkbox } from "../components/ui/checkbox"; import { Toaster } from "../components/ui/sonner"; import { toast } from "sonner"; import { - closeSession, listSessions, resumeSession, getSession, + closeSession, getMe, listSessions, resumeSession, getSession, renameSession, setSessionGroup, archiveSession, unarchiveSession, deleteSession, prewarmRuntime, } from "../api/sessions"; -import type { SessionSummary } from "../api/types"; +import type { Principal, SessionScope, SessionSummary } from "../api/types"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useEffect, useMemo, useRef, useState } from "react"; import type { CSSProperties } from "react"; @@ -48,8 +48,10 @@ export function AppShell() { const [streamCursorResetEpoch, setStreamCursorResetEpoch] = useState(0); const [creatingSession, setCreatingSession] = useState(false); const [awaitingQuestion, setAwaitingQuestion] = useState(false); + const [sessionScope, setSessionScope] = useState("mine"); + const { data: principal } = useQuery({ queryKey: ["me"], queryFn: getMe, staleTime: Infinity }); const { data: sessions = [] } = useQuery({ - queryKey: ["sessions"], queryFn: listSessions, refetchInterval: 10_000, + queryKey: ["sessions", sessionScope], queryFn: () => listSessions(sessionScope), refetchInterval: 10_000, }); const composerRef = useRef(null); @@ -77,6 +79,12 @@ export function AppShell() { const finalized = activeSession?.status === "finalized"; const selectedSessions = sessions.filter((session) => selectedSessionIds.has(session.id)); const allSessionsSelected = sessions.length > 0 && selectedSessions.length === sessions.length; + const showingAllSessions = sessionScope === "all"; + const isForeignSession = (session: SessionSummary) => { + if (!showingAllSessions || !principal) return false; + if (!session.author) return true; + return session.author !== principal.subject && session.author !== principal.displayName; + }; function selectActiveSession(id: string | null) { // Keep async Resume completions synchronized before React commits the state update. @@ -245,6 +253,14 @@ export function AppShell() { } } + function requestArchiveToggle(session: SessionSummary) { + if (!session.archived && isForeignSession(session)) { + const label = session.author ? `${session.author}'s session` : "this session"; + if (!window.confirm(`Archive ${label}?`)) return; + } + void toggleArchive(session); + } + async function deleteSessions(targets: SessionSummary[]) { try { const results = await Promise.allSettled(targets.map((session) => deleteSession(session.id))); @@ -266,6 +282,14 @@ export function AppShell() { } } + function requestDelete(targets: SessionSummary[]) { + if (targets.some(isForeignSession) || allSessionsSelected) { + setDeleteTargets(targets); + return; + } + void deleteSessions(targets); + } + function menuFor(s: SessionSummary) { return ( setRenameTarget(s)} onMove={(g) => move(s, g)} onNewGroup={() => newGroup(s)} - onArchiveToggle={() => toggleArchive(s)} - onDelete={() => { void deleteSessions([s]); }} + onArchiveToggle={() => requestArchiveToggle(s)} + onDelete={() => requestDelete([s])} /> ); } @@ -496,6 +520,32 @@ export function AppShell() { + {principal?.isAdmin && ( +
+
+ + +
+ {showingAllSessions && ( +

+ Administrator view: all sessions +

+ )} +
+ )} + {/* L1 — rail title */}
@@ -519,8 +569,7 @@ export function AppShell() { size="xs" aria-label={`Delete ${selectedSessions.length} selected sessions`} onClick={() => { - if (allSessionsSelected) setDeleteTargets(selectedSessions); - else void deleteSessions(selectedSessions); + requestDelete(selectedSessions); }} > @@ -571,6 +620,7 @@ export function AppShell() { menuFor={menuFor} selectedIds={selectedSessionIds} onSelectionChange={setSessionSelected} + showOwner={showingAllSessions} /> )}
@@ -586,6 +636,7 @@ export function AppShell() { menuFor={menuFor} selectedIds={selectedSessionIds} onSelectionChange={setSessionSelected} + showOwner={showingAllSessions} /> )} @@ -609,6 +660,7 @@ export function AppShell() { menuFor={menuFor} selectedIds={selectedSessionIds} onSelectionChange={setSessionSelected} + showOwner={showingAllSessions} /> )} diff --git a/frontend/src/shell/NavSessions.test.tsx b/frontend/src/shell/NavSessions.test.tsx index 48042570..9a633182 100644 --- a/frontend/src/shell/NavSessions.test.tsx +++ b/frontend/src/shell/NavSessions.test.tsx @@ -26,6 +26,19 @@ test("active session is highlighted", () => { expect(screen.getByTestId("session-item-s2")).toHaveAttribute("data-active", "true"); }); +test("administrator session lists show the recorded owner", () => { + render( + null} + showOwner + />, + ); + expect(screen.getByText("Owner: Alice")).toBeInTheDocument(); +}); + test("pressing Enter on a focused row opens the panel", async () => { const onOpenPanel = vi.fn(); render( null} />); diff --git a/frontend/src/shell/NavSessions.tsx b/frontend/src/shell/NavSessions.tsx index 00fb4a04..927d371d 100644 --- a/frontend/src/shell/NavSessions.tsx +++ b/frontend/src/shell/NavSessions.tsx @@ -10,6 +10,7 @@ interface Props { menuFor: (session: SessionSummary) => ReactNode; selectedIds?: ReadonlySet; onSelectionChange?: (id: string, selected: boolean) => void; + showOwner?: boolean; } function statusIndicator(status: string) { @@ -21,7 +22,7 @@ function statusIndicator(status: string) { export function NavSessions({ sessions, activeSessionId, onOpenPanel, menuFor, - selectedIds = new Set(), onSelectionChange = () => undefined, + selectedIds = new Set(), onSelectionChange = () => undefined, showOwner = false, }: Props) { if (sessions.length === 0) { return ( @@ -70,13 +71,20 @@ export function NavSessions({ title={indicator.label} className={["size-1.5 shrink-0 rounded-full", indicator.color].join(" ")} /> - - {label} + + + {label} + + {showOwner && ( + + Owner: {s.author ?? "Unknown"} + + )} {menuFor(s)}