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
+99 -28
View File
@@ -1,4 +1,4 @@
import { useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
AlertCircle,
@@ -25,6 +25,7 @@ import {
validateWorkspace,
type WorkspaceRuntimeConfiguration,
} from "../api/workspaces";
import { captureAuthOperation, isAuthOperationCurrent, StaleAuthOperationError } from "../auth/authOperation";
import { Button } from "../components/ui/button";
import {
Dialog,
@@ -60,7 +61,17 @@ function stateLabel(state: "ready" | "configuration_required"): string {
const workspaceAuthoringGuideUrl =
"https://github.com/mptyl/ThothII/blob/main/docs/install/local-workspace-registry.md#prepare-and-publish-a-workspace-source";
export function WorkspaceManager({ open, onClose }: { open: boolean; onClose: () => void }) {
export function WorkspaceManager({
open,
onClose,
canManageWorkspace = false,
canManageSecrets = false,
}: {
open: boolean;
onClose: () => void;
canManageWorkspace?: boolean;
canManageSecrets?: boolean;
}) {
const queryClient = useQueryClient();
const [selectedId, setSelectedId] = useState<string>();
const [secretValues, setSecretValues] = useState<Record<string, string>>({});
@@ -71,15 +82,34 @@ export function WorkspaceManager({ open, onClose }: { open: boolean; onClose: ()
const [connectionNotice, setConnectionNotice] = useState<string>();
const [connectionDiagnostics, setConnectionDiagnostics] = useState<string[]>([]);
const [busyAction, setBusyAction] = useState<string>();
const operationEpochRef = useRef(0);
const selectedIdRef = useRef(selectedId);
selectedIdRef.current = selectedId;
useEffect(() => () => { operationEpochRef.current += 1; }, []);
async function guardedQuery<T>(request: () => Promise<T>, targetId?: string): Promise<T> {
const guard = captureAuthOperation({
sessionId: targetId ?? null,
disposalEpoch: operationEpochRef.current,
});
if (!guard) throw new StaleAuthOperationError();
const result = await request();
const currentSessionId = targetId === undefined ? null : selectedIdRef.current;
if (!isAuthOperationCurrent(guard, {
sessionId: currentSessionId,
disposalEpoch: operationEpochRef.current,
})) throw new StaleAuthOperationError();
return result;
}
const statusQuery = useQuery({
queryKey: ["workspace-repository-status"],
queryFn: getWorkspaceRegistryStatus,
queryFn: () => guardedQuery(getWorkspaceRegistryStatus),
enabled: open,
});
const workspacesQuery = useQuery({
queryKey: ["workspaces"],
queryFn: listWorkspaces,
queryFn: () => guardedQuery(listWorkspaces),
enabled: open,
});
const workspaces = workspacesQuery.data ?? [];
@@ -89,12 +119,12 @@ export function WorkspaceManager({ open, onClose }: { open: boolean; onClose: ()
);
const detailQuery = useQuery({
queryKey: ["workspace", selectedId],
queryFn: () => getWorkspace(selectedId!),
queryFn: () => guardedQuery(() => getWorkspace(selectedId!), selectedId),
enabled: Boolean(open && selectedId),
});
const runtimeQuery = useQuery({
queryKey: ["workspace-runtime-configuration", selectedId],
queryFn: () => getWorkspaceRuntimeConfiguration(selectedId!),
queryFn: () => guardedQuery(() => getWorkspaceRuntimeConfiguration(selectedId!), selectedId),
enabled: Boolean(open && selectedId),
});
@@ -131,48 +161,63 @@ export function WorkspaceManager({ open, onClose }: { open: boolean; onClose: ()
};
async function updateRepository() {
const guard = captureAuthOperation({ disposalEpoch: operationEpochRef.current });
if (!guard) return;
setBusyAction("repository");
clearMessages();
try {
await pullWorkspaceRegistry();
if (!isAuthOperationCurrent(guard, { disposalEpoch: operationEpochRef.current })) return;
await Promise.all([
statusQuery.refetch(),
workspacesQuery.refetch(),
selectedId ? detailQuery.refetch() : Promise.resolve(),
selectedId ? runtimeQuery.refetch() : Promise.resolve(),
]);
if (!isAuthOperationCurrent(guard, { disposalEpoch: operationEpochRef.current })) return;
setNotice("Workspace repository updated and validated.");
} catch (error) {
setDiagnostics([publicError(error, "git_unavailable: Workspace repository could not be updated")]);
if (isAuthOperationCurrent(guard, { disposalEpoch: operationEpochRef.current })) {
setDiagnostics([publicError(error, "git_unavailable: Workspace repository could not be updated")]);
}
} finally {
setBusyAction(undefined);
if (isAuthOperationCurrent(guard, { disposalEpoch: operationEpochRef.current })) setBusyAction(undefined);
}
}
async function validateSource() {
if (!detailQuery.data) return;
const guard = captureAuthOperation({ sessionId: selectedId, disposalEpoch: operationEpochRef.current });
if (!guard) return;
setBusyAction("validate");
clearGlobalMessages();
setValidationNotice(undefined);
setValidationDiagnostics([]);
try {
await validateWorkspace(detailQuery.data.workspace);
if (!isAuthOperationCurrent(guard, { sessionId: selectedIdRef.current, disposalEpoch: operationEpochRef.current })) return;
setValidationNotice("Workspace source is valid.");
} catch (error) {
setValidationDiagnostics([publicError(error, "workspace_invalid: Workspace validation could not be completed")]);
if (isAuthOperationCurrent(guard, { sessionId: selectedIdRef.current, disposalEpoch: operationEpochRef.current })) {
setValidationDiagnostics([publicError(error, "workspace_invalid: Workspace validation could not be completed")]);
}
} finally {
setBusyAction(undefined);
if (isAuthOperationCurrent(guard, { sessionId: selectedIdRef.current, disposalEpoch: operationEpochRef.current })) setBusyAction(undefined);
}
}
async function testConnections() {
if (!selectedId) return;
const targetId = selectedId;
const guard = captureAuthOperation({ sessionId: targetId, disposalEpoch: operationEpochRef.current });
if (!guard) return;
setBusyAction("test");
clearGlobalMessages();
setConnectionNotice(undefined);
setConnectionDiagnostics([]);
try {
const result = await testWorkspace(selectedId);
if (!isAuthOperationCurrent(guard, { sessionId: selectedIdRef.current, disposalEpoch: operationEpochRef.current })) return;
const issues = result.diagnostics.filter(({ level }) => level !== "info");
const informational = result.diagnostics.find(({ level }) => level === "info");
setConnectionDiagnostics(issues.map(({ code, message }) => `${code}: ${message}`));
@@ -184,14 +229,19 @@ export function WorkspaceManager({ open, onClose }: { open: boolean; onClose: ()
: "Workspace connection test completed.");
}
} catch (error) {
setConnectionDiagnostics([publicError(error, "connector_unavailable: Workspace connections could not be tested")]);
if (isAuthOperationCurrent(guard, { sessionId: selectedIdRef.current, disposalEpoch: operationEpochRef.current })) {
setConnectionDiagnostics([publicError(error, "connector_unavailable: Workspace connections could not be tested")]);
}
} finally {
setBusyAction(undefined);
if (isAuthOperationCurrent(guard, { sessionId: selectedIdRef.current, disposalEpoch: operationEpochRef.current })) setBusyAction(undefined);
}
}
async function saveSecrets() {
if (!selectedId) return;
const targetId = selectedId;
const guard = captureAuthOperation({ sessionId: targetId, disposalEpoch: operationEpochRef.current });
if (!guard) return;
const values = Object.fromEntries(
Object.entries(secretValues).filter(([, value]) => value.length > 0),
);
@@ -199,38 +249,49 @@ export function WorkspaceManager({ open, onClose }: { open: boolean; onClose: ()
setBusyAction("save-secrets");
clearMessages();
try {
const configuration = await saveWorkspaceSecrets(selectedId, values);
const configuration = await saveWorkspaceSecrets(targetId, values);
if (!isAuthOperationCurrent(guard, { sessionId: selectedIdRef.current, disposalEpoch: operationEpochRef.current })) return;
queryClient.setQueryData(
["workspace-runtime-configuration", selectedId],
["workspace-runtime-configuration", targetId],
configuration,
);
setSecretValues({});
await workspacesQuery.refetch();
if (!isAuthOperationCurrent(guard, { sessionId: selectedIdRef.current, disposalEpoch: operationEpochRef.current })) return;
setNotice("Runtime secrets saved. Stored values remain hidden.");
} catch (error) {
setDiagnostics([publicError(error, "workspace_invalid: Runtime secrets could not be saved")]);
if (isAuthOperationCurrent(guard, { sessionId: selectedIdRef.current, disposalEpoch: operationEpochRef.current })) {
setDiagnostics([publicError(error, "workspace_invalid: Runtime secrets could not be saved")]);
}
} finally {
setBusyAction(undefined);
if (isAuthOperationCurrent(guard, { sessionId: selectedIdRef.current, disposalEpoch: operationEpochRef.current })) setBusyAction(undefined);
}
}
async function forgetSecret(requirementId: string) {
if (!selectedId) return;
const targetId = selectedId;
const guard = captureAuthOperation({ sessionId: targetId, disposalEpoch: operationEpochRef.current });
if (!guard) return;
setBusyAction(`forget:${requirementId}`);
clearMessages();
try {
const configuration = await forgetWorkspaceSecret(selectedId, requirementId);
const configuration = await forgetWorkspaceSecret(targetId, requirementId);
if (!isAuthOperationCurrent(guard, { sessionId: selectedIdRef.current, disposalEpoch: operationEpochRef.current })) return;
queryClient.setQueryData(
["workspace-runtime-configuration", selectedId],
["workspace-runtime-configuration", targetId],
configuration,
);
setSecretValues((current) => ({ ...current, [requirementId]: "" }));
await workspacesQuery.refetch();
if (!isAuthOperationCurrent(guard, { sessionId: selectedIdRef.current, disposalEpoch: operationEpochRef.current })) return;
setNotice("Stored secret forgotten.");
} catch (error) {
setDiagnostics([publicError(error, "workspace_invalid: Stored secret could not be forgotten")]);
if (isAuthOperationCurrent(guard, { sessionId: selectedIdRef.current, disposalEpoch: operationEpochRef.current })) {
setDiagnostics([publicError(error, "workspace_invalid: Stored secret could not be forgotten")]);
}
} finally {
setBusyAction(undefined);
if (isAuthOperationCurrent(guard, { sessionId: selectedIdRef.current, disposalEpoch: operationEpochRef.current })) setBusyAction(undefined);
}
}
@@ -322,7 +383,7 @@ export function WorkspaceManager({ open, onClose }: { open: boolean; onClose: ()
<p className="text-sm leading-6 text-muted-foreground">
Follow the <a href={workspaceAuthoringGuideUrl} target="_blank" rel="noreferrer" className="font-medium text-foreground underline underline-offset-4 hover:text-primary">workspace authoring instructions on GitHub</a> for the required layout and validation rules.
</p>
<div className="rounded-lg border border-border bg-muted/30 p-4">
{canManageWorkspace && <div className="rounded-lg border border-border bg-muted/30 p-4">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="max-w-2xl">
<h3 className="font-heading font-semibold">Update workspace repository</h3>
@@ -332,7 +393,12 @@ export function WorkspaceManager({ open, onClose }: { open: boolean; onClose: ()
<GitPullRequest />Update workspace repository
</Button>
</div>
</div>
</div>}
{!canManageWorkspace && (
<p className="rounded-lg border border-border bg-muted/30 p-4 text-sm text-muted-foreground">
You can inspect workspaces. Workspace updates, validation, and connection tests require workspace management permission.
</p>
)}
<p className="text-sm text-muted-foreground">Select a workspace from the left only for workspace-specific validation, runtime credentials, and connection tests.</p>
</section>
) : (
@@ -387,9 +453,9 @@ export function WorkspaceManager({ open, onClose }: { open: boolean; onClose: ()
))}
</div>
)}
<Button className="mt-3" size="sm" variant="outline" disabled={busyAction === "validate"} onClick={() => { void validateSource(); }}>
{canManageWorkspace && <Button className="mt-3" size="sm" variant="outline" disabled={busyAction === "validate"} onClick={() => { void validateSource(); }}>
<ClipboardCheck />Validate workspace source
</Button>
</Button>}
</div>
<div data-testid="workspace-connection-card" className="rounded-lg border border-border p-4">
<h4 className="font-heading font-semibold">Test workspace connections</h4>
@@ -408,13 +474,13 @@ export function WorkspaceManager({ open, onClose }: { open: boolean; onClose: ()
))}
</div>
)}
<Button className="mt-3" size="sm" variant="outline" disabled={busyAction === "test"} onClick={() => { void testConnections(); }}>
{canManageWorkspace && <Button className="mt-3" size="sm" variant="outline" disabled={busyAction === "test"} onClick={() => { void testConnections(); }}>
<FlaskConical />Test workspace connections
</Button>
</Button>}
</div>
</div>
<div className="rounded-lg border border-border p-4">
{canManageSecrets && <div className="rounded-lg border border-border p-4">
<div className="mb-4 flex items-start gap-3">
<KeyRound className="mt-0.5 size-5 text-primary" />
<div>
@@ -478,7 +544,12 @@ export function WorkspaceManager({ open, onClose }: { open: boolean; onClose: ()
</div>
</div>
)}
</div>
</div>}
{!canManageSecrets && (
<p className="rounded-lg border border-border bg-muted/30 p-4 text-sm text-muted-foreground">
Runtime secret controls are available only to authorized workspace operators.
</p>
)}
</>
)}
</section>