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
+142 -44
View File
@@ -21,19 +21,23 @@ import { Checkbox } from "../components/ui/checkbox";
import { Toaster } from "../components/ui/sonner";
import { toast } from "sonner";
import {
closeSession, getMe, listSessions, resumeSession, getSession,
closeSession, listSessions, resumeSession, getSession,
renameSession, setSessionGroup, archiveSession, unarchiveSession, deleteSession, prewarmRuntime,
checkDwhHealth,
} from "../api/sessions";
import { logout as logoutUser } from "../api/auth";
import {
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
} from "../components/ui/dialog";
import type { Principal, SessionScope, SessionSummary } from "../api/types";
import type { SessionScope, SessionSummary } from "../api/types";
import { useAuthGeneration, useAuthUser } from "../auth/authState";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect, useMemo, useRef, useState } from "react";
import type { CSSProperties } from "react";
import { captureAuthOperation, isAuthOperationCurrent, StaleAuthOperationError, type AuthOperationGuard } from "../auth/authOperation";
export function AppShell() {
const authenticatedUser = useAuthUser();
const [panelSession, setPanelSession] = useState<SessionSummary | null>(null);
const {
containerRef,
@@ -81,14 +85,29 @@ export function AppShell() {
const resumeInFlightRef = useRef(new Map<string, {
latestToken: number;
promise: Promise<void>;
guard: AuthOperationGuard;
}>());
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 principal = authenticatedUser;
const permissions = authenticatedUser?.permissions ?? [];
const canReadAllSessions = permissions.includes("session.read_all");
const canManageWorkspace = permissions.includes("workspace.manage");
const canManageWorkspaceSecrets = permissions.includes("workspace.secrets.manage");
const canManagePi = permissions.includes("pi.manage");
const authGeneration = useAuthGeneration();
const { data: sessions = [] } = useQuery<SessionSummary[]>({
queryKey: ["sessions", sessionScope], queryFn: () => listSessions(sessionScope), refetchInterval: 10_000,
queryKey: ["sessions", sessionScope],
queryFn: async () => {
const guard = captureAuthOperation({ disposalEpoch: operationEpochRef.current });
if (!guard) throw new StaleAuthOperationError();
const result = await listSessions(sessionScope);
if (!isAuthOperationCurrent(guard, { disposalEpoch: operationEpochRef.current })) throw new StaleAuthOperationError();
return result;
},
refetchInterval: 10_000,
});
const composerRef = useRef<HTMLTextAreaElement>(null);
@@ -107,11 +126,15 @@ export function AppShell() {
const [dwhDown, setDwhDown] = useState(false);
const [dwhChecking, setDwhChecking] = useState(true);
const [dwhCheckEpoch, setDwhCheckEpoch] = useState(0);
const operationEpochRef = useRef(0);
useEffect(() => () => { operationEpochRef.current += 1; }, []);
useEffect(() => {
let cancelled = false;
const operation = captureAuthOperation({ disposalEpoch: operationEpochRef.current });
if (!operation) return () => { cancelled = true; };
setDwhChecking(true);
checkDwhHealth().then((r) => {
if (cancelled) return;
if (cancelled || !isAuthOperationCurrent(operation, { disposalEpoch: operationEpochRef.current })) return;
setDwhDown(!r.ok);
setDwhChecking(false);
});
@@ -125,7 +148,10 @@ export function AppShell() {
const activeList = sessions.filter((s) => !s.archived);
const ungroupedActive = activeList.filter((s) => !s.group);
const archivedList = sessions.filter((s) => s.archived);
const refresh = () => queryClient.invalidateQueries({ queryKey: ["sessions"] });
const refresh = (operation?: AuthOperationGuard | null) => {
if (operation && !isAuthOperationCurrent(operation, { disposalEpoch: operationEpochRef.current })) return;
void queryClient.invalidateQueries({ queryKey: ["sessions"] });
};
const activeSession = sessions.find((s) => s.id === activeSessionId) ?? null;
const finalized = activeSession?.status === "finalized";
const selectedSessions = sessions.filter((session) => selectedSessionIds.has(session.id));
@@ -158,6 +184,10 @@ export function AppShell() {
});
}, [sessions]);
useEffect(() => {
if (!canReadAllSessions && sessionScope === "all") setSessionScope("mine");
}, [canReadAllSessions, sessionScope]);
function setSessionSelected(id: string, selected: boolean) {
setSelectedSessionIds((current) => {
const next = new Set(current);
@@ -193,10 +223,12 @@ export function AppShell() {
});
}
async function doResume(id: string) {
const guard = captureAuthOperation({ sessionId: id, disposalEpoch: operationEpochRef.current });
if (!guard) return;
const token = ++resumeInvocationRef.current;
latestResumeIntentRef.current = { token, id };
const inFlight = resumeInFlightRef.current.get(id);
if (inFlight) {
if (inFlight && isAuthOperationCurrent(inFlight.guard, { sessionId: id, disposalEpoch: operationEpochRef.current })) {
// Repeated intent for the same target shares one backend lifecycle operation and one
// commit path. Updating its token still lets s1→s2→s1 make the final s1 intent authoritative.
inFlight.latestToken = token;
@@ -206,8 +238,9 @@ export function AppShell() {
const operation = {
latestToken: token,
promise: Promise.resolve(),
guard,
};
operation.promise = runResume(id, operation).finally(() => {
operation.promise = runResume(id, operation, guard).finally(() => {
if (resumeInFlightRef.current.get(id) === operation) {
resumeInFlightRef.current.delete(id);
}
@@ -219,9 +252,11 @@ export function AppShell() {
async function runResume(
id: string,
operation: { latestToken: number; promise: Promise<void> },
guard: AuthOperationGuard,
) {
try {
const result = await resumeSession(id);
if (!isAuthOperationCurrent(guard, { sessionId: id, disposalEpoch: operationEpochRef.current })) return;
const latest = latestResumeIntentRef.current;
if (latest?.token !== operation.latestToken || latest.id !== id) return;
const reconnectSameSession = activeSessionIdRef.current === id;
@@ -254,6 +289,7 @@ export function AppShell() {
// The manifest's `phase` is the 1-based current phase (1..8).
try {
const m = (await getSession(id)) as { phase?: number };
if (!isAuthOperationCurrent(guard, { sessionId: id, disposalEpoch: operationEpochRef.current })) return;
const latestAfterManifest = latestResumeIntentRef.current;
if (
latestAfterManifest?.token !== operation.latestToken
@@ -267,7 +303,7 @@ export function AppShell() {
/* non-fatal: the first gate will set the phase */
}
} catch {
if (
if (isAuthOperationCurrent(guard, { sessionId: id, disposalEpoch: operationEpochRef.current }) &&
latestResumeIntentRef.current?.token === operation.latestToken
&& latestResumeIntentRef.current.id === id
) {
@@ -276,41 +312,73 @@ export function AppShell() {
}
}
async function move(s: SessionSummary, group: string) {
const guard = captureAuthOperation({ sessionId: s.id, disposalEpoch: operationEpochRef.current });
if (!guard) return;
try {
await setSessionGroup(s.id, group); refresh();
await setSessionGroup(s.id, group);
if (!isAuthOperationCurrent(guard, { sessionId: s.id, disposalEpoch: operationEpochRef.current })) return;
refresh(guard);
} catch {
toast.error("Failed to move session.");
if (isAuthOperationCurrent(guard, { sessionId: s.id, disposalEpoch: operationEpochRef.current })) toast.error("Failed to move session.");
}
}
async function newGroup(s: SessionSummary) {
const name = window.prompt("New group:");
if (name && name.trim()) {
const guard = captureAuthOperation({ sessionId: s.id, disposalEpoch: operationEpochRef.current });
if (!guard) return;
try {
await setSessionGroup(s.id, name.trim()); refresh();
await setSessionGroup(s.id, name.trim());
if (!isAuthOperationCurrent(guard, { sessionId: s.id, disposalEpoch: operationEpochRef.current })) return;
refresh(guard);
} catch {
toast.error("Failed to update group.");
if (isAuthOperationCurrent(guard, { sessionId: s.id, disposalEpoch: operationEpochRef.current })) toast.error("Failed to update group.");
}
}
}
async function renameGroup(oldName: string, newName: string) {
async function renameGroup(oldName: string, newName: string, suppliedGuard?: AuthOperationGuard | null) {
const trimmed = newName.trim();
if (!trimmed || trimmed === oldName) return;
if (!trimmed || trimmed === oldName) return false;
const guard = suppliedGuard ?? captureAuthOperation({ disposalEpoch: operationEpochRef.current });
if (!guard) return false;
try {
for (const s of sessions.filter((x) => x.group === oldName)) {
await setSessionGroup(s.id, trimmed);
if (!isAuthOperationCurrent(guard, { disposalEpoch: operationEpochRef.current })) return false;
}
refresh();
refresh(guard);
return true;
} catch {
toast.error("Failed to rename group.");
if (isAuthOperationCurrent(guard, { disposalEpoch: operationEpochRef.current })) toast.error("Failed to rename group.");
return false;
}
}
async function renameSessionFromDialog(id: string, name: string) {
const guard = captureAuthOperation({ sessionId: id, disposalEpoch: operationEpochRef.current });
if (!guard) return;
try {
await renameSession(id, name);
if (!isAuthOperationCurrent(guard, { sessionId: id, disposalEpoch: operationEpochRef.current })) return;
setRenameTarget(null);
refresh(guard);
} catch {
if (isAuthOperationCurrent(guard, { sessionId: id, disposalEpoch: operationEpochRef.current })) {
toast.error("Failed to rename session.");
}
}
}
async function toggleArchive(s: SessionSummary) {
const guard = captureAuthOperation({ sessionId: s.id, disposalEpoch: operationEpochRef.current });
if (!guard) return;
try {
await (s.archived ? unarchiveSession(s.id) : archiveSession(s.id));
if (!isAuthOperationCurrent(guard, { sessionId: s.id, disposalEpoch: operationEpochRef.current })) return;
if (panelSession?.id === s.id) setPanelSession(null);
refresh();
refresh(guard);
} catch {
toast.error(s.archived ? "Failed to restore session." : "Failed to archive session.");
if (isAuthOperationCurrent(guard, { sessionId: s.id, disposalEpoch: operationEpochRef.current })) {
toast.error(s.archived ? "Failed to restore session." : "Failed to archive session.");
}
}
}
@@ -323,8 +391,11 @@ export function AppShell() {
}
async function deleteSessions(targets: SessionSummary[]) {
const guard = captureAuthOperation({ disposalEpoch: operationEpochRef.current });
if (!guard) return;
try {
const results = await Promise.allSettled(targets.map((session) => deleteSession(session.id)));
if (!isAuthOperationCurrent(guard, { disposalEpoch: operationEpochRef.current })) return;
const deletedIds = new Set(
targets.filter((_, index) => results[index].status === "fulfilled").map((session) => session.id),
);
@@ -334,12 +405,12 @@ export function AppShell() {
if (deletedIds.has(panelSession?.id ?? "")) setPanelSession(null);
if (deletedActiveSession) { resetSession(); selectActiveSession(null); }
setSelectedSessionIds((current) => new Set([...current].filter((id) => !deletedIds.has(id))));
refresh();
refresh(guard);
if (deletedIds.size !== targets.length) {
toast.error(`Deleted ${deletedIds.size} of ${targets.length} sessions.`);
}
} catch {
toast.error("Failed to delete selected sessions.");
if (isAuthOperationCurrent(guard, { disposalEpoch: operationEpochRef.current })) toast.error("Failed to delete selected sessions.");
}
}
@@ -401,7 +472,13 @@ export function AppShell() {
// session sits idle or a gate awaits the reviewer (pendingWidget).
const running = working && !finalized;
useSessionStream(activeSessionId, 0, streamCursorResetEpoch);
useSessionStream(
activeSessionId,
0,
streamCursorResetEpoch,
Boolean(authenticatedUser),
authGeneration,
);
// A backend "session_exit" system event (e.g. the replay server emitting it
// when the reviewer picks "Esci") asks us to leave the live session view and
@@ -463,16 +540,24 @@ export function AppShell() {
async function stopSession() {
if (!activeSessionId) return;
const id = activeSessionId;
const guard = captureAuthOperation({ sessionId: id, disposalEpoch: operationEpochRef.current });
if (!guard) return;
invalidateResumeIntent();
try {
await closeSession(activeSessionId);
await closeSession(id);
} finally {
if (!isAuthOperationCurrent(guard, { sessionId: id, disposalEpoch: operationEpochRef.current })) return;
resetSession();
selectActiveSession(null);
setAwaitingQuestion(false);
}
}
async function signOut() {
await logoutUser();
}
return (
<div
ref={containerRef}
@@ -611,6 +696,14 @@ export function AppShell() {
<br />
Human In The Loop
</p>
{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>
<Button variant="ghost" size="xs" onClick={() => { void signOut().catch(() => undefined); }}>Log out</Button>
</div>
)}
</div>
<div className="flex flex-col gap-2 px-4 pb-3">
@@ -630,17 +723,19 @@ export function AppShell() {
>
Workspace management
</Button>
<Button
variant="outline"
size="sm"
className="w-full"
onClick={() => setPiManagementOpen(true)}
>
Pi management
</Button>
{canManagePi && (
<Button
variant="outline"
size="sm"
className="w-full"
onClick={() => setPiManagementOpen(true)}
>
Pi management
</Button>
)}
</div>
{principal?.isAdmin && (
{canReadAllSessions && (
<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
@@ -788,8 +883,13 @@ export function AppShell() {
</div>
</aside>
)}
<WorkspaceManager open={workspaceManagerOpen} onClose={() => setWorkspaceManagerOpen(false)} />
<PiManagement open={piManagementOpen} onClose={() => setPiManagementOpen(false)} />
<WorkspaceManager
open={workspaceManagerOpen}
onClose={() => setWorkspaceManagerOpen(false)}
canManageWorkspace={canManageWorkspace}
canManageSecrets={canManageWorkspaceSecrets}
/>
{canManagePi && <PiManagement open={piManagementOpen} onClose={() => setPiManagementOpen(false)} />}
<Toaster />
@@ -804,15 +904,7 @@ export function AppShell() {
open
initial={renameTarget.name ?? ""}
onOpenChange={(o) => { if (!o) setRenameTarget(null); }}
onSubmit={async (name) => {
try {
await renameSession(renameTarget.id, name);
setRenameTarget(null);
refresh();
} catch {
toast.error("Failed to rename session.");
}
}}
onSubmit={(name) => renameSessionFromDialog(renameTarget.id, name)}
/>
)}
{renameGroupTarget && (
@@ -820,7 +912,13 @@ export function AppShell() {
open
initial={renameGroupTarget}
onOpenChange={(o) => { if (!o) setRenameGroupTarget(null); }}
onSubmit={async (name) => { await renameGroup(renameGroupTarget, name); setRenameGroupTarget(null); }}
onSubmit={async (name) => {
const target = renameGroupTarget;
const guard = captureAuthOperation({ disposalEpoch: operationEpochRef.current });
if (await renameGroup(target, name, guard) && guard && isAuthOperationCurrent(guard, { disposalEpoch: operationEpochRef.current })) {
setRenameGroupTarget(null);
}
}}
title="Rename group"
/>
)}