feat(auth): add remembered local login to the frontend

This commit is contained in:
2026-08-17 04:52:39 +02:00
parent 8c67cb75dc
commit 202822f3ba
36 changed files with 2649 additions and 163 deletions
@@ -5,10 +5,22 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { server } from "../test/msw";
import { FakeEventSource } from "../test/fakeEventSource";
import { AppShell } from "./AppShell";
import type { AuthenticatedUser } from "../api/types";
import { clearAuthState, setAuthState } from "../auth/authState";
import { useSessionStore } from "../store/sessionStore";
import { ACTIVITY_PANEL_STORAGE_KEY } from "./useActivityPanelResize";
function wrap() {
const regularUser: AuthenticatedUser = {
issuer: "portal", subject: "alice", displayName: "Alice", roles: ["user"] as const,
permissions: ["session.use", "pi.manage"] as const, isAdmin: false, csrfToken: null, session: null,
};
const adminUser: AuthenticatedUser = {
...regularUser, subject: "alice-id", roles: ["admin"] as const,
permissions: ["session.use", "session.read_all", "pi.manage"] as const, isAdmin: true,
};
function wrap(user: AuthenticatedUser = regularUser) {
if (user !== regularUser) setAuthState(user);
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(<QueryClientProvider client={client}><AppShell /></QueryClientProvider>);
}
@@ -47,6 +59,8 @@ function deferred() {
}
beforeEach(() => {
clearAuthState();
setAuthState(regularUser);
localStorage.clear();
FakeEventSource.instances = [];
ControlledResizeObserver.instances = [];
@@ -57,13 +71,18 @@ beforeEach(() => {
useSessionStore.getState().resetSession();
server.use(
http.get("/api/me", () =>
HttpResponse.json({ issuer: "portal", subject: "alice", displayName: "Alice", isAdmin: false }),
HttpResponse.json({ issuer: "portal", subject: "alice", displayName: "Alice", isAdmin: false, permissions: ["session.use", "pi.manage"] }),
),
http.get("/api/settings", () => HttpResponse.json({ workspace: "psd", provider: "zai", model: "glm-5.2", thinking: "medium" })),
http.get("/api/workspaces", () => HttpResponse.json([])),
http.get("/api/models", () => HttpResponse.json({ models: [] })),
http.get("/api/sessions", () => HttpResponse.json(LIST)),
http.get("/api/sessions/:id", () => HttpResponse.json({ id: "s1", status: "open", phase: 1 })),
http.get("/api/sessions/:id/documents", () => HttpResponse.json([
{ phase: "—", key: "question", title: "Domanda originale", format: "text", content: "Attiva uno" },
])),
http.post("/api/sessions/:id/archive", () => new HttpResponse(null, { status: 204 })),
http.post("/api/runtime/prewarm", () => new HttpResponse(null, { status: 202 })),
);
});
@@ -135,7 +154,7 @@ test("administrators can explicitly switch to all sessions and see owners", asyn
]);
}),
);
wrap();
wrap(adminUser);
await screen.findByRole("button", { name: "All sessions" });
expect(screen.getByRole("button", { name: "My sessions" })).toHaveAttribute("aria-pressed", "true");
expect(screen.getByRole("button", { name: "All sessions" })).toHaveAttribute("aria-pressed", "false");
@@ -162,7 +181,7 @@ test("administrator confirms before deleting a same-named user's session", async
return new HttpResponse(null, { status: 204 });
}),
);
wrap();
wrap(adminUser);
await userEvent.click(await screen.findByRole("button", { name: "All sessions" }));
await screen.findByText("Owner: Alice");
await userEvent.click(screen.getByRole("checkbox", { name: "Select Attiva uno" }));
@@ -188,7 +207,7 @@ test("administrator confirms before archiving a same-named user's session", asyn
return new HttpResponse(null, { status: 204 });
}),
);
wrap();
wrap(adminUser);
await userEvent.click(await screen.findByRole("button", { name: "All sessions" }));
await screen.findByText("Owner: Alice");
await userEvent.click(screen.getByRole("button", { name: "Session actions" }));
@@ -735,6 +754,63 @@ test("starting a new question invalidates a pending Resume intent", async () =>
expect(screen.getByText(/type your question/i)).toBeInTheDocument();
});
test("a delayed Resume from user A cannot activate a session after user B logs in", async () => {
const resumeGate = deferred();
const resumeStarted = deferred();
const resumeSettled = deferred();
server.use(
http.post("/api/sessions/:id/resume", async () => {
resumeStarted.resolve();
try {
await resumeGate.promise;
return resumeResult("s1");
} finally {
resumeSettled.resolve();
}
}),
http.get("/api/sessions/:id", () => HttpResponse.json({ id: "s1", status: "open", phase: 4 })),
);
wrap();
await userEvent.click(await screen.findByText("Attiva uno"));
await userEvent.click(await screen.findByRole("button", { name: /resume/i }));
await resumeStarted.promise;
act(() => setAuthState({ ...regularUser, subject: "bob", displayName: "Bob" }));
resumeGate.resolve();
await act(async () => { await resumeSettled.promise; });
expect(FakeEventSource.instances).toHaveLength(0);
expect(useSessionStore.getState().currentPhase).toBeNull();
expect(screen.queryByRole("button", { name: "Hide model activity" })).not.toBeInTheDocument();
});
test("a delayed session deletion from user A cannot refresh or toast into user B", async () => {
const deleteGate = deferred();
const deleteStarted = deferred();
const deleteSettled = deferred();
server.use(http.delete("/api/sessions/:id", async () => {
deleteStarted.resolve();
try {
await deleteGate.promise;
return new HttpResponse(null, { status: 204 });
} finally {
deleteSettled.resolve();
}
}));
wrap();
await screen.findByText("Attiva uno");
screen.getByRole("checkbox", { name: "Select Attiva uno" }).focus();
await userEvent.keyboard(" ");
await userEvent.click(await screen.findByRole("button", { name: "Delete 1 selected sessions" }));
await deleteStarted.promise;
act(() => setAuthState({ ...regularUser, subject: "bob", displayName: "Bob" }));
deleteGate.resolve();
await act(async () => { await deleteSettled.promise; });
expect(useSessionStore.getState().toasts).toEqual([]);
expect(screen.queryByText("Deleted 1 of 1 sessions.")).not.toBeInTheDocument();
expect(screen.getByTestId("session-item-s1")).toBeInTheDocument();
});
test("a successful Delete invalidates an earlier pending Resume for the same target", async () => {
const resumeGate = deferred();
const resumeStarted = deferred();