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