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