feat(auth): add remembered local login to the frontend
This commit is contained in:
+142
-44
@@ -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"
|
||||
/>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user