Files
ThothII/frontend/src/shell/WorkspaceManager.tsx
T

698 lines
36 KiB
TypeScript

import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
AlertCircle,
ArrowLeft,
CheckCircle2,
ClipboardCheck,
FlaskConical,
GitPullRequest,
KeyRound,
Trash2,
X,
} from "lucide-react";
import {
asWorkspaceApiError,
forgetWorkspaceSecret,
getWorkspace,
getWorkspaceRegistryStatus,
getWorkspaceRuntimeConfiguration,
listWorkspaces,
pullWorkspaceRegistry,
saveWorkspaceSecrets,
testWorkspace,
validateWorkspace,
type AuthDiagnostics,
type WorkspaceRuntimeConfiguration,
} from "../api/workspaces";
import {
captureAuthOperation,
isAuthOperationCurrent,
StaleAuthOperationError,
type AuthOperationGuard,
} from "../auth/authOperation";
import { Button } from "../components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "../components/ui/dialog";
function QueryError({ name, message, retryLabel, onRetry }: {
name: string;
message: string;
retryLabel: string;
onRetry: () => void;
}) {
return (
<div role="alert" aria-label={name} className="grid gap-2 rounded-md border border-destructive/30 bg-destructive/5 p-3 text-sm">
<p>{message}</p>
<div><Button size="sm" variant="outline" onClick={onRetry}>{retryLabel}</Button></div>
</div>
);
}
function publicError(error: unknown, fallback: string): string {
const safe = asWorkspaceApiError(error);
return safe ? `${safe.code}: ${safe.message}` : fallback;
}
function stateLabel(state: "ready" | "configuration_required"): string {
return state === "ready" ? "Ready" : "Runtime configuration required";
}
const workspaceAuthoringGuideUrl =
"https://github.com/mptyl/ThothII/blob/main/docs/install/local-workspace-registry.md#prepare-and-publish-a-workspace-source";
type WorkspaceOperationContext = Readonly<{
open: boolean;
canManageWorkspace: boolean;
canManageSecrets: boolean;
}>;
type WorkspaceOperationGuard = Readonly<{
operation: AuthOperationGuard;
context: WorkspaceOperationContext;
}>;
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>>({});
const [notice, setNotice] = useState<string>();
const [diagnostics, setDiagnostics] = useState<string[]>([]);
const [validationNotice, setValidationNotice] = useState<string>();
const [validationDiagnostics, setValidationDiagnostics] = useState<string[]>([]);
const [connectionNotice, setConnectionNotice] = useState<string>();
const [connectionDiagnostics, setConnectionDiagnostics] = useState<string[]>([]);
const [authentication, setAuthentication] = useState<AuthDiagnostics>();
const [busyAction, setBusyAction] = useState<string>();
const operationEpochRef = useRef(0);
const diagnosticEpochRef = useRef(0);
const selectedIdRef = useRef(selectedId);
const contextRef = useRef<WorkspaceOperationContext>({ open, canManageWorkspace, canManageSecrets });
const committedContextRef = useRef<WorkspaceOperationContext>(contextRef.current);
selectedIdRef.current = selectedId;
contextRef.current = { open, canManageWorkspace, canManageSecrets };
const clearWorkspacePresentation = () => {
setNotice(undefined);
setDiagnostics([]);
setValidationNotice(undefined);
setValidationDiagnostics([]);
setConnectionNotice(undefined);
setConnectionDiagnostics([]);
setAuthentication(undefined);
};
const cancelWorkspaceQueries = () => {
void queryClient.cancelQueries({ queryKey: ["workspace-repository-status"] });
void queryClient.cancelQueries({ queryKey: ["workspaces"] });
void queryClient.cancelQueries({ queryKey: ["workspace"] });
void queryClient.cancelQueries({ queryKey: ["workspace-runtime-configuration"] });
};
const clearWorkspaceCaches = () => {
queryClient.removeQueries({ queryKey: ["workspace-repository-status"] });
queryClient.removeQueries({ queryKey: ["workspaces"] });
queryClient.removeQueries({ queryKey: ["workspace"] });
queryClient.removeQueries({ queryKey: ["workspace-runtime-configuration"] });
};
// This is the sole invalidation boundary for deferred workspace work. Call it before capturing
// an operation guard; doing it after capture would immediately invalidate the new operation.
const invalidateWorkspaceContext = ({
clearSecrets = true,
clearCaches = false,
}: { clearSecrets?: boolean; clearCaches?: boolean } = {}) => {
operationEpochRef.current += 1;
diagnosticEpochRef.current += 1;
cancelWorkspaceQueries();
if (clearCaches) clearWorkspaceCaches();
setBusyAction(undefined);
if (clearSecrets) {
setSecretValues({});
}
clearWorkspacePresentation();
};
useEffect(() => () => {
operationEpochRef.current += 1;
diagnosticEpochRef.current += 1;
}, []);
useLayoutEffect(() => {
const previous = committedContextRef.current;
const current = contextRef.current;
const openChanged = previous.open !== current.open;
const permissionsChanged = previous.canManageWorkspace !== current.canManageWorkspace
|| previous.canManageSecrets !== current.canManageSecrets;
if (openChanged || permissionsChanged) {
invalidateWorkspaceContext({ clearCaches: permissionsChanged });
}
committedContextRef.current = current;
}, [open, canManageWorkspace, canManageSecrets]);
function captureWorkspaceOperation(targetId?: string): WorkspaceOperationGuard | null {
const context = contextRef.current;
if (!context.open) return null;
const operation = captureAuthOperation({
sessionId: targetId ?? null,
disposalEpoch: operationEpochRef.current,
});
return operation ? { operation, context } : null;
}
function isWorkspaceOperationCurrent(guard: WorkspaceOperationGuard | null, targetId?: string): boolean {
if (!guard) return false;
const context = contextRef.current;
return context.open === guard.context.open
&& context.canManageWorkspace === guard.context.canManageWorkspace
&& context.canManageSecrets === guard.context.canManageSecrets
&& isAuthOperationCurrent(guard.operation, {
sessionId: targetId ?? null,
disposalEpoch: operationEpochRef.current,
});
}
async function guardedQuery<T>(request: () => Promise<T>, targetId?: string): Promise<T> {
const guard = captureWorkspaceOperation(targetId);
if (!guard) throw new StaleAuthOperationError();
const result = await request();
const currentTargetId = targetId === undefined ? undefined : selectedIdRef.current;
if (!isWorkspaceOperationCurrent(guard, currentTargetId)) throw new StaleAuthOperationError();
return result;
}
const statusQuery = useQuery({
queryKey: ["workspace-repository-status"],
queryFn: () => guardedQuery(getWorkspaceRegistryStatus),
enabled: open,
});
const workspacesQuery = useQuery({
queryKey: ["workspaces"],
queryFn: () => guardedQuery(listWorkspaces),
enabled: open,
});
const workspaces = workspacesQuery.data ?? [];
const selectedSummary = useMemo(
() => workspaces.find(({ id }) => id === selectedId),
[selectedId, workspaces],
);
const detailQuery = useQuery({
queryKey: ["workspace", selectedId],
queryFn: () => guardedQuery(() => getWorkspace(selectedId!), selectedId),
enabled: Boolean(open && selectedId),
});
const runtimeQuery = useQuery({
queryKey: ["workspace-runtime-configuration", selectedId],
queryFn: () => guardedQuery(() => getWorkspaceRuntimeConfiguration(selectedId!), selectedId),
enabled: Boolean(open && selectedId),
});
const close = () => {
invalidateWorkspaceContext();
onClose();
};
const selectWorkspace = (id: string) => {
invalidateWorkspaceContext();
setSelectedId(id);
};
const showLevelOne = () => {
invalidateWorkspaceContext();
setSelectedId(undefined);
};
async function updateRepository() {
invalidateWorkspaceContext({ clearCaches: true });
const guard = captureWorkspaceOperation();
if (!guard) return;
const targetId = selectedIdRef.current;
setBusyAction("repository");
try {
await pullWorkspaceRegistry();
if (!isWorkspaceOperationCurrent(guard)) return;
await Promise.all([
statusQuery.refetch(),
workspacesQuery.refetch(),
targetId ? detailQuery.refetch() : Promise.resolve(),
targetId ? runtimeQuery.refetch() : Promise.resolve(),
]);
if (!isWorkspaceOperationCurrent(guard)) return;
setNotice("Workspace repository updated and validated.");
} catch (error) {
if (isWorkspaceOperationCurrent(guard)) {
setDiagnostics([publicError(error, "git_unavailable: Workspace repository could not be updated")]);
}
} finally {
if (isWorkspaceOperationCurrent(guard)) setBusyAction(undefined);
}
}
async function validateSource() {
if (!detailQuery.data) return;
const targetId = selectedId;
const source = detailQuery.data.workspace;
invalidateWorkspaceContext({ clearSecrets: false });
const guard = captureWorkspaceOperation(targetId);
if (!guard) return;
const diagnosticEpoch = diagnosticEpochRef.current;
setBusyAction("validate");
try {
const result = await validateWorkspace(source);
if (diagnosticEpoch !== diagnosticEpochRef.current ||
!isWorkspaceOperationCurrent(guard, selectedIdRef.current)) return;
setAuthentication(result.authentication);
setValidationNotice(result.activatable ? "Workspace source and authentication are valid." : "Workspace source is valid.");
} catch (error) {
if (diagnosticEpoch === diagnosticEpochRef.current &&
isWorkspaceOperationCurrent(guard, selectedIdRef.current)) {
setValidationDiagnostics([publicError(error, "workspace_invalid: Workspace validation could not be completed")]);
}
} finally {
if (diagnosticEpoch === diagnosticEpochRef.current &&
isWorkspaceOperationCurrent(guard, selectedIdRef.current)) setBusyAction(undefined);
}
}
async function testConnections() {
if (!selectedId) return;
const targetId = selectedId;
invalidateWorkspaceContext({ clearSecrets: false });
const guard = captureWorkspaceOperation(targetId);
if (!guard) return;
const diagnosticEpoch = diagnosticEpochRef.current;
setBusyAction("test");
try {
const result = await testWorkspace(targetId);
if (diagnosticEpoch !== diagnosticEpochRef.current ||
!isWorkspaceOperationCurrent(guard, selectedIdRef.current)) return;
setAuthentication(result.authentication);
const issues = result.diagnostics.filter(({ level }) => level !== "info");
const informational = result.diagnostics.find(({ level }) => level === "info");
setConnectionDiagnostics(issues.map(({ code, message }) => `${code}: ${message}`));
if (issues.length === 0) {
setConnectionNotice(result.activatable
? informational
? `${informational.code}: ${informational.message}`
: "Workspace connections are valid."
: "Workspace connection test completed.");
}
} catch (error) {
if (diagnosticEpoch === diagnosticEpochRef.current &&
isWorkspaceOperationCurrent(guard, selectedIdRef.current)) {
setConnectionDiagnostics([publicError(error, "connector_unavailable: Workspace connections could not be tested")]);
}
} finally {
if (diagnosticEpoch === diagnosticEpochRef.current &&
isWorkspaceOperationCurrent(guard, selectedIdRef.current)) setBusyAction(undefined);
}
}
async function saveSecrets() {
if (!selectedId) return;
const targetId = selectedId;
const values = Object.fromEntries(
Object.entries(secretValues).filter(([, value]) => value.length > 0),
);
if (Object.keys(values).length === 0) return;
invalidateWorkspaceContext({ clearSecrets: false });
const guard = captureWorkspaceOperation(targetId);
if (!guard) return;
setBusyAction("save-secrets");
try {
const configuration = await saveWorkspaceSecrets(targetId, values);
if (!isWorkspaceOperationCurrent(guard, selectedIdRef.current)) return;
queryClient.setQueryData(
["workspace-runtime-configuration", targetId],
configuration,
);
setSecretValues({});
await workspacesQuery.refetch();
if (!isWorkspaceOperationCurrent(guard, selectedIdRef.current)) return;
setNotice("Runtime secrets saved. Stored values remain hidden.");
} catch (error) {
if (isWorkspaceOperationCurrent(guard, selectedIdRef.current)) {
setDiagnostics([publicError(error, "workspace_invalid: Runtime secrets could not be saved")]);
}
} finally {
if (isWorkspaceOperationCurrent(guard, selectedIdRef.current)) setBusyAction(undefined);
}
}
async function forgetSecret(requirementId: string) {
if (!selectedId) return;
const targetId = selectedId;
invalidateWorkspaceContext({ clearSecrets: false });
const guard = captureWorkspaceOperation(targetId);
if (!guard) return;
setBusyAction(`forget:${requirementId}`);
try {
const configuration = await forgetWorkspaceSecret(targetId, requirementId);
if (!isWorkspaceOperationCurrent(guard, selectedIdRef.current)) return;
queryClient.setQueryData(
["workspace-runtime-configuration", targetId],
configuration,
);
setSecretValues((current) => ({ ...current, [requirementId]: "" }));
await workspacesQuery.refetch();
if (!isWorkspaceOperationCurrent(guard, selectedIdRef.current)) return;
setNotice("Stored secret forgotten.");
} catch (error) {
if (isWorkspaceOperationCurrent(guard, selectedIdRef.current)) {
setDiagnostics([publicError(error, "workspace_invalid: Stored secret could not be forgotten")]);
}
} finally {
if (isWorkspaceOperationCurrent(guard, selectedIdRef.current)) setBusyAction(undefined);
}
}
const repository = statusQuery.data?.repository;
const repositoryLabel = repository
? `${repository.host}/${repository.repository}`
: "the repository configured for this ThothII installation";
const repositoryWebUrl = repository && repository.transport !== "local"
? `https://${repository.host}/${repository.repository}`
: undefined;
const runtime = runtimeQuery.data;
const hasEnteredSecrets = Object.values(secretValues).some((value) => value.length > 0);
return (
<Dialog open={open} onOpenChange={(nextOpen) => { if (!nextOpen) close(); }}>
<DialogContent
showCloseButton={false}
className="h-[86vh] w-[94vw] max-h-[92vh] max-w-[94vw] grid-rows-[auto_minmax(0,1fr)] gap-0 overflow-hidden p-0 sm:w-[70vw] sm:max-w-[94vw]"
>
<DialogHeader className="border-b border-border/70 px-6 pb-4 pt-5 pr-12">
<DialogTitle>Workspace management</DialogTitle>
<DialogDescription>
Read, validate, and complete the runtime configuration of workspaces supplied by the installation repository.
</DialogDescription>
</DialogHeader>
<button type="button" aria-label="Close workspace management" onClick={close} className="absolute right-3 top-3 grid size-7 place-items-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground">
<X className="size-4" />
</button>
<div className="grid min-h-0 grid-cols-[15rem_minmax(0,1fr)] overflow-hidden">
<nav aria-label="Workspaces" className="flex min-h-0 flex-col border-r border-border/70 bg-muted/30 p-3">
<Button
type="button"
size="sm"
variant="outline"
className="mb-3 w-full justify-start"
onClick={showLevelOne}
>
<ArrowLeft />Back to Level 1
</Button>
<p className="mb-2 px-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">Available workspaces</p>
<div className="min-h-0 flex-1 overflow-y-auto">
{workspacesQuery.isLoading && <p className="p-2 text-xs text-muted-foreground">Loading workspaces…</p>}
{workspacesQuery.isError && (
<QueryError name="Workspace list failed" message="Could not load workspaces." retryLabel="Retry workspace list" onRetry={() => { void workspacesQuery.refetch(); }} />
)}
{workspaces.map((workspace) => (
<button
key={workspace.id}
type="button"
aria-label={workspace.displayName}
aria-current={selectedId === workspace.id ? "page" : undefined}
onClick={() => selectWorkspace(workspace.id)}
className="mb-1 w-full rounded-md px-2.5 py-2 text-left text-sm hover:bg-muted aria-[current=page]:bg-primary/10 aria-[current=page]:font-semibold"
>
<span className="block truncate">{workspace.displayName}</span>
<span className="block truncate text-xs text-muted-foreground">{workspace.id}</span>
<span className="mt-1 block text-[0.7rem] text-muted-foreground">{stateLabel(workspace.configurationState)}</span>
</button>
))}
</div>
</nav>
<main className="min-h-0 min-w-0 overflow-y-auto overscroll-contain px-6 py-5">
{notice && (
<p role="status" className="mb-4 flex items-center gap-2 rounded-md border border-border bg-muted/50 px-3 py-2 text-sm">
<CheckCircle2 className="size-4 text-primary" />{notice}
</p>
)}
{diagnostics.length > 0 && (
<div data-testid="workspace-diagnostics" aria-live="polite" className="mb-4 grid gap-1 rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-sm">
{diagnostics.map((diagnostic) => (
<p key={diagnostic} className="flex items-start gap-2">
<AlertCircle className="mt-0.5 size-4 shrink-0 text-amber-700" />{diagnostic}
</p>
))}
</div>
)}
{!selectedSummary ? (
<section data-testid="workspace-overview" className="mx-auto max-w-3xl space-y-5">
<div>
<p className="thot-label">Level 1 · Repository</p>
<h2 className="font-heading text-xl font-semibold">What workspace files tell ThothII</h2>
</div>
<div className="grid gap-3 text-sm leading-6 text-muted-foreground">
<p>
A workspace file describes one data environment. Its <code>workspace.yaml</code> gives ThothII the workspace identity and language, the database target and schema it may query, the Evidence sources that support analysis, and the processing settings to apply. Runtime credentials are stored separately from Git.
</p>
<p>
One Git repository can contain many workspaces. At its root, <code>thoth-workspaces.yaml</code> lists them, and each workspace has its own directory, <code>workspace.yaml</code>, database target and access method, and Evidence sources. This keeps separate data environments and supporting material distinct while versioning them together.
</p>
</div>
<div>
<h3 className="font-heading font-semibold text-foreground">How this installation uses the repository</h3>
<ol className="mt-1 grid list-decimal gap-0 pl-5 text-sm leading-6 text-muted-foreground">
<li>
The repository address, branch, and read-only Git credentials are configured during ThothII installation. This installation reads{" "}
{repositoryWebUrl ? (
<a href={repositoryWebUrl} target="_blank" rel="noreferrer" className="break-all font-medium text-foreground underline underline-offset-4 hover:text-primary">{repositoryLabel}</a>
) : (
<span className="font-medium text-foreground">{repositoryLabel}</span>
)}{" "}
on branch <span className="font-mono text-foreground">{statusQuery.data?.branch ?? "main"}</span>.
</li>
<li>ThothII fetches the configured branch into its managed read-only checkout, reads the workspaces listed in the repository, and validates the complete candidate revision before activating it.</li>
<li>The active revision stays read-only. ThothII never edits, commits, pushes, or publishes workspace files; if validation fails, the previous valid revision stays active.</li>
</ol>
</div>
<p className="text-sm leading-6 text-muted-foreground">
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> explain the required layout and validation rules for people who maintain the repository.
</p>
{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>
<p className="mt-1 text-sm leading-6 text-muted-foreground">Fetches the configured branch directly into the managed read-only checkout and validates it. No workspace selection is required. If candidate validation fails, the current active revision remains unchanged.</p>
</div>
<Button disabled={busyAction === "repository"} onClick={() => { void updateRepository(); }}>
<GitPullRequest />Update workspace repository
</Button>
</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 leading-6 text-muted-foreground">Selecting a workspace from the left opens its own revision, database and Evidence configuration, runtime requirements, validation, and connection checks.</p>
</section>
) : (
<section className="mx-auto max-w-4xl space-y-5">
<div className="border-b border-border/70 pb-4">
<p className="thot-label">Level 2 · Selected workspace</p>
<h2 className="font-heading text-xl font-semibold">{selectedSummary.displayName}</h2>
<p className="font-mono text-xs text-muted-foreground">{selectedSummary.id}</p>
</div>
{(detailQuery.isLoading || runtimeQuery.isLoading) && <p className="text-sm text-muted-foreground">Loading workspace configuration…</p>}
{(detailQuery.isError || runtimeQuery.isError) && (
<QueryError name="Workspace configuration failed" message="Could not load this workspace configuration." retryLabel="Retry workspace configuration" onRetry={() => { void Promise.all([detailQuery.refetch(), runtimeQuery.refetch()]); }} />
)}
{detailQuery.data && runtime && (
<>
<div className="max-w-3xl">
<h3 className="font-heading text-lg font-semibold">About this workspace</h3>
<p className="mt-1 text-sm leading-6 text-muted-foreground">
<span className="font-medium text-foreground">{selectedSummary.displayName}</span> is one workspace in the shared repository. Its workspace.yaml defines the database target, processing settings, and Evidence sources for this data environment. Other workspaces in the same repository can use different databases and keep their Evidence separate.
</p>
<p className="mt-2 text-sm leading-6 text-muted-foreground">
The active revision shown below is the exact version currently activated by ThothII. Reading or checking it does not modify the repository. Source changes are made in Git and become available after a new revision is fetched, validated, and activated.
</p>
</div>
<dl className="grid gap-3 rounded-lg border border-border bg-muted/20 p-4 text-sm sm:grid-cols-2">
<div><dt className="text-xs text-muted-foreground">Source file</dt><dd className="font-mono">{selectedSummary.file}</dd></div>
<div><dt className="text-xs text-muted-foreground">Active revision</dt><dd className="truncate font-mono">{detailQuery.data.revision.commit}</dd></div>
<div>
<dt className="text-xs text-muted-foreground">Database</dt>
<dd className="grid gap-0.5 font-mono text-xs">
<span>engine: {detailQuery.data.workspace.dwh.engine}</span>
<span>database: {detailQuery.data.workspace.dwh.database}</span>
<span>schema: {detailQuery.data.workspace.dwh.schema}</span>
</dd>
</div>
<div><dt className="text-xs text-muted-foreground">Runtime status</dt><dd>{stateLabel(runtime.configurationState)}</dd></div>
</dl>
<div>
<h3 className="font-heading text-lg font-semibold">Workspace-specific actions</h3>
<p className="mt-1 text-sm leading-6 text-muted-foreground">The validation and connection checks below apply only to <span className="font-medium text-foreground">{selectedSummary.displayName}</span> and read its active revision without changing or publishing it.</p>
</div>
<div className="grid gap-3 lg:grid-cols-2">
<div data-testid="workspace-validation-card" className="rounded-lg border border-border p-4">
<h4 className="font-heading font-semibold">Validate workspace source</h4>
<p className="mt-1 min-h-12 text-sm leading-5 text-muted-foreground">Confirms that workspace.yaml and the directories belonging to this workspace follow the supported format. This identifies source problems before activation, without changing repository files.</p>
{validationNotice && (
<p role="status" className="mt-3 rounded-md border border-emerald-500/30 bg-emerald-500/10 px-3 py-2 text-sm text-emerald-700">
<CheckCircle2 className="mr-1 inline size-4 text-emerald-600" />{validationNotice}
</p>
)}
{validationDiagnostics.length > 0 && (
<div role="alert" aria-live="polite" className="mt-3 grid gap-1 rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-sm">
{validationDiagnostics.map((diagnostic) => (
<p key={diagnostic} className="flex items-start gap-2">
<AlertCircle className="mt-0.5 size-4 shrink-0 text-amber-700" />{diagnostic}
</p>
))}
</div>
)}
{canManageWorkspace && <Button className="mt-3" size="sm" variant="outline" disabled={busyAction === "validate"} onClick={() => { void validateSource(); }}>
<ClipboardCheck />Validate workspace source
</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>
<p className="mt-1 min-h-12 text-sm leading-5 text-muted-foreground">Verifies that ThothII can reach the data warehouse and Evidence sources configured for this workspace. It decrypts credentials only for the duration of the test and deletes temporary files when the check finishes.</p>
{connectionNotice && (
<p role="status" className="mt-3 rounded-md border border-emerald-500/30 bg-emerald-500/10 px-3 py-2 text-sm text-emerald-700">
<CheckCircle2 className="mr-1 inline size-4 text-emerald-600" />{connectionNotice}
</p>
)}
{connectionDiagnostics.length > 0 && (
<div role="alert" aria-live="polite" className="mt-3 grid gap-1 rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-sm">
{connectionDiagnostics.map((diagnostic) => (
<p key={diagnostic} className="flex items-start gap-2">
<AlertCircle className="mt-0.5 size-4 shrink-0 text-amber-700" />{diagnostic}
</p>
))}
</div>
)}
{canManageWorkspace && <Button className="mt-3" size="sm" variant="outline" disabled={busyAction === "test"} onClick={() => { void testConnections(); }}>
<FlaskConical />Test workspace connections
</Button>}
</div>
</div>
{authentication && <section data-testid="workspace-authentication" className="rounded-lg border border-border p-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<h4 className="font-heading font-semibold">Authentication</h4>
<span className={authentication.ready ? "text-sm font-medium text-emerald-700" : "text-sm font-medium text-amber-700"}>
{authentication.ready ? "Passed" : "Failed"}
</span>
</div>
{authentication.checks.some(({ level }) => level === "error") && <div role="alert" className="mt-3 grid gap-1 rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-sm">
{authentication.checks.filter(({ level }) => level === "error").map(({ code, field, message }) => (
<p key={`${code}:${field ?? ""}`} className="flex items-start gap-2">
<AlertCircle className="mt-0.5 size-4 shrink-0 text-amber-700" />
{code}: {field === undefined ? message : `${field} — ${message}`}
</p>
))}
</div>}
</section>}
{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>
<h3 className="font-heading font-semibold">Runtime secrets</h3>
<p className="mt-1 text-sm leading-5 text-muted-foreground">Some connectors need credentials at runtime. ThothII stores these values separately from Git and never displays stored values. Enter a value only when adding or replacing a credential for this workspace.</p>
</div>
</div>
{runtime.requirements.length === 0 ? (
<p className="text-sm text-muted-foreground">This workspace does not require user-provided runtime secrets for its selected connectors.</p>
) : (
<div className="grid gap-4">
{runtime.requirements.map((requirement) => (
<div key={requirement.id} className="grid gap-2 rounded-md bg-muted/30 p-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<label htmlFor={`workspace-secret-${requirement.id}`} className="font-medium">{requirement.label}</label>
<span className={requirement.configured ? "text-xs font-medium text-emerald-700" : "text-xs font-medium text-amber-700"}>
{requirement.configured ? "Configured" : "Not configured"}
</span>
</div>
<p className="text-xs text-muted-foreground">{requirement.description}{requirement.required ? " Required for this workspace." : " Optional."}</p>
{requirement.input === "textarea" ? (
<textarea
id={`workspace-secret-${requirement.id}`}
value={secretValues[requirement.id] ?? ""}
placeholder="Enter a new or replacement value"
autoComplete="off"
rows={4}
onChange={(event) => setSecretValues((current) => ({ ...current, [requirement.id]: event.target.value }))}
className="w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
) : (
<input
id={`workspace-secret-${requirement.id}`}
type="password"
value={secretValues[requirement.id] ?? ""}
placeholder="Enter a new or replacement value"
autoComplete="new-password"
onChange={(event) => setSecretValues((current) => ({ ...current, [requirement.id]: event.target.value }))}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
)}
{requirement.configured && (
<div>
<Button
size="sm"
variant="ghost"
disabled={busyAction === `forget:${requirement.id}`}
aria-label={`Forget stored ${requirement.label}`}
onClick={() => { void forgetSecret(requirement.id); }}
>
<Trash2 />Forget stored value
</Button>
</div>
)}
</div>
))}
<div>
<Button disabled={!hasEnteredSecrets || busyAction === "save-secrets"} onClick={() => { void saveSecrets(); }}>
<KeyRound />Save entered secrets
</Button>
</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>
)}
</main>
</div>
</DialogContent>
</Dialog>
);
}