feat(frontend): expose owned session scopes

This commit is contained in:
User
2026-07-16 18:46:18 +02:00
parent b454fb478b
commit 58e0884df1
8 changed files with 270 additions and 20 deletions
+51
View File
@@ -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.
+27 -3
View File
@@ -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 () => {
+7 -2
View File
@@ -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<void>("/runtime/prewarm", { method: "POST" });
export const listSessions = () => apiFetch<SessionSummary[]>("/sessions");
export const getMe = () => apiFetch<Principal>("/me");
export const listSessions = (scope: SessionScope = "mine") =>
apiFetch<SessionSummary[]>(`/sessions?scope=${scope}`);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const getSession = (id: string) => apiFetch<any>(`/sessions/${id}`);
+9
View File
@@ -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;
@@ -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();
+59 -7
View File
@@ -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<SessionScope>("mine");
const { data: principal } = useQuery<Principal>({ queryKey: ["me"], queryFn: getMe, staleTime: Infinity });
const { data: sessions = [] } = useQuery<SessionSummary[]>({
queryKey: ["sessions"], queryFn: listSessions, refetchInterval: 10_000,
queryKey: ["sessions", sessionScope], queryFn: () => listSessions(sessionScope), refetchInterval: 10_000,
});
const composerRef = useRef<HTMLTextAreaElement>(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 (
<SessionMenu
@@ -276,8 +300,8 @@ export function AppShell() {
onRename={() => 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() {
</Button>
</div>
{principal?.isAdmin && (
<div className="px-4 pb-3">
<div className="grid grid-cols-2 gap-1 rounded-lg bg-muted p-1" aria-label="Session scope">
<Button
variant={sessionScope === "mine" ? "secondary" : "ghost"}
size="xs"
onClick={() => setSessionScope("mine")}
>
My sessions
</Button>
<Button
variant={showingAllSessions ? "secondary" : "ghost"}
size="xs"
onClick={() => setSessionScope("all")}
>
All sessions
</Button>
</div>
{showingAllSessions && (
<p className="mt-2 text-xs font-medium text-amber-700 dark:text-amber-400">
Administrator view: all sessions
</p>
)}
</div>
)}
{/* L1 — rail title */}
<div className="px-4 pb-1.5 pt-1">
<span className="thot-label text-[0.8rem] font-bold tracking-[0.18em] text-primary">
@@ -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);
}}
>
<Trash2 />
@@ -571,6 +620,7 @@ export function AppShell() {
menuFor={menuFor}
selectedIds={selectedSessionIds}
onSelectionChange={setSessionSelected}
showOwner={showingAllSessions}
/>
)}
</div>
@@ -586,6 +636,7 @@ export function AppShell() {
menuFor={menuFor}
selectedIds={selectedSessionIds}
onSelectionChange={setSessionSelected}
showOwner={showingAllSessions}
/>
)}
</div>
@@ -609,6 +660,7 @@ export function AppShell() {
menuFor={menuFor}
selectedIds={selectedSessionIds}
onSelectionChange={setSessionSelected}
showOwner={showingAllSessions}
/>
)}
</div>
+13
View File
@@ -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(
<NavSessions
sessions={[{ ...SESSIONS[0], author: "Alice" }]}
activeSessionId={null}
onOpenPanel={vi.fn()}
menuFor={() => null}
showOwner
/>,
);
expect(screen.getByText("Owner: Alice")).toBeInTheDocument();
});
test("pressing Enter on a focused row opens the panel", async () => {
const onOpenPanel = vi.fn();
render(<NavSessions sessions={SESSIONS} activeSessionId={null} onOpenPanel={onOpenPanel} menuFor={() => null} />);
+16 -8
View File
@@ -10,6 +10,7 @@ interface Props {
menuFor: (session: SessionSummary) => ReactNode;
selectedIds?: ReadonlySet<string>;
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<string>(), onSelectionChange = () => undefined,
selectedIds = new Set<string>(), 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(" ")}
/>
<span
className={[
"min-w-0 flex-1 truncate text-[0.8rem] leading-snug",
active ? "font-semibold text-foreground" : "font-medium text-foreground/90",
].join(" ")}
>
{label}
<span className="min-w-0 flex-1">
<span
className={[
"block truncate text-[0.8rem] leading-snug",
active ? "font-semibold text-foreground" : "font-medium text-foreground/90",
].join(" ")}
>
{label}
</span>
{showOwner && (
<span className="block truncate text-[0.65rem] text-muted-foreground">
Owner: {s.author ?? "Unknown"}
</span>
)}
</span>
{menuFor(s)}
</div>