fix(auth): close cancellation and workspace races
This commit is contained in:
@@ -62,6 +62,38 @@ function renderDeniedManager() {
|
||||
);
|
||||
}
|
||||
|
||||
type ManagerSettings = {
|
||||
open: boolean;
|
||||
canManageWorkspace: boolean;
|
||||
canManageSecrets: boolean;
|
||||
};
|
||||
|
||||
function renderManagerWithSettings(initial: Partial<ManagerSettings> = {}) {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
const onClose = vi.fn();
|
||||
let settings: ManagerSettings = {
|
||||
open: true,
|
||||
canManageWorkspace: true,
|
||||
canManageSecrets: true,
|
||||
...initial,
|
||||
};
|
||||
const tree = () => (
|
||||
<QueryClientProvider client={client}>
|
||||
<WorkspaceManager onClose={onClose} {...settings} />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
const view = render(tree());
|
||||
return {
|
||||
...view,
|
||||
client,
|
||||
onClose,
|
||||
rerender(next: Partial<ManagerSettings>) {
|
||||
settings = { ...settings, ...next };
|
||||
view.rerender(tree());
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
setAuthState(authenticatedWorkspaceUser);
|
||||
@@ -610,6 +642,377 @@ test("a delayed runtime-secret save from user A cannot repopulate user B's cache
|
||||
expect(screen.queryByText("Runtime secrets saved. Stored values remain hidden.")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("a deferred secret save cannot update a reopened dialog", async () => {
|
||||
const user = userEvent.setup();
|
||||
let release!: () => void;
|
||||
let started!: () => void;
|
||||
let settled!: () => void;
|
||||
const held = new Promise<void>((resolve) => { release = resolve; });
|
||||
const requestStarted = new Promise<void>((resolve) => { started = resolve; });
|
||||
const requestSettled = new Promise<void>((resolve) => { settled = resolve; });
|
||||
server.use(http.put("/api/workspaces/psd-clinical/secrets", async () => {
|
||||
started();
|
||||
try {
|
||||
await held;
|
||||
return HttpResponse.json(runtimeConfiguration(true));
|
||||
} finally {
|
||||
settled();
|
||||
}
|
||||
}));
|
||||
const manager = renderManagerWithSettings();
|
||||
await user.click(await screen.findByRole("button", { name: "PSD Clinical" }));
|
||||
await user.type(await screen.findByLabelText("Data warehouse password"), "one-time-password");
|
||||
await user.click(screen.getByRole("button", { name: "Save entered secrets" }));
|
||||
await requestStarted;
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Close workspace management" }));
|
||||
manager.rerender({ open: false });
|
||||
manager.rerender({ open: true });
|
||||
await user.type(await screen.findByLabelText("Data warehouse password"), "new-secret");
|
||||
expect(await screen.findByRole("button", { name: "Save entered secrets" })).not.toBeDisabled();
|
||||
const cacheBeforeRelease = manager.client.getQueryData(["workspace-runtime-configuration", "psd-clinical"]);
|
||||
|
||||
act(() => release());
|
||||
await act(async () => {
|
||||
await requestSettled;
|
||||
await new Promise((resolve) => { setTimeout(resolve, 50); });
|
||||
});
|
||||
|
||||
expect(manager.client.getQueryData(["workspace-runtime-configuration", "psd-clinical"])).toEqual(cacheBeforeRelease);
|
||||
expect(screen.queryByText("Runtime secrets saved. Stored values remain hidden.")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("a deferred secret save cannot update a workspace selected again after a cross-workspace transition", async () => {
|
||||
const user = userEvent.setup();
|
||||
const alternateId = "other-clinical";
|
||||
const alternateWorkspace = canonicalWorkspaceFixture(alternateId);
|
||||
const alternateRevision = workspaceRevisionFixture(alternateId);
|
||||
let release!: () => void;
|
||||
let started!: () => void;
|
||||
let settled!: () => void;
|
||||
const held = new Promise<void>((resolve) => { release = resolve; });
|
||||
const requestStarted = new Promise<void>((resolve) => { started = resolve; });
|
||||
const requestSettled = new Promise<void>((resolve) => { settled = resolve; });
|
||||
server.use(
|
||||
http.get("/api/workspaces", () => HttpResponse.json([
|
||||
workspaceSummaryFixture("psd-clinical", {
|
||||
displayName: "PSD Clinical", description: "Clinical data", configurationState: "configuration_required", revision,
|
||||
}),
|
||||
workspaceSummaryFixture(alternateId, {
|
||||
displayName: "Other Clinical", description: "Other data", configurationState: "configuration_required", revision: alternateRevision,
|
||||
}),
|
||||
])),
|
||||
http.get(`/api/workspaces/${alternateId}`, () => HttpResponse.json({ workspace: alternateWorkspace, revision: alternateRevision })),
|
||||
http.get(`/api/workspaces/${alternateId}/runtime-configuration`, () => HttpResponse.json({
|
||||
workspaceId: alternateId, revision: alternateRevision, configurationState: "configuration_required", requirements: [{ ...requirement }],
|
||||
})),
|
||||
http.put("/api/workspaces/psd-clinical/secrets", async () => {
|
||||
started();
|
||||
try {
|
||||
await held;
|
||||
return HttpResponse.json(runtimeConfiguration(true));
|
||||
} finally {
|
||||
settled();
|
||||
}
|
||||
}),
|
||||
);
|
||||
const manager = renderManagerWithSettings();
|
||||
await user.click(await screen.findByRole("button", { name: "PSD Clinical" }));
|
||||
await user.type(await screen.findByLabelText("Data warehouse password"), "one-time-password");
|
||||
await user.click(screen.getByRole("button", { name: "Save entered secrets" }));
|
||||
await requestStarted;
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Other Clinical" }));
|
||||
expect(await screen.findByRole("heading", { name: "Other Clinical" })).toBeVisible();
|
||||
await user.click(screen.getByRole("button", { name: "PSD Clinical" }));
|
||||
await user.type(await screen.findByLabelText("Data warehouse password"), "new-secret");
|
||||
expect(await screen.findByRole("button", { name: "Save entered secrets" })).not.toBeDisabled();
|
||||
const cacheBeforeRelease = manager.client.getQueryData(["workspace-runtime-configuration", "psd-clinical"]);
|
||||
|
||||
act(() => release());
|
||||
await act(async () => {
|
||||
await requestSettled;
|
||||
await new Promise((resolve) => { setTimeout(resolve, 50); });
|
||||
});
|
||||
|
||||
expect(manager.client.getQueryData(["workspace-runtime-configuration", "psd-clinical"])).toEqual(cacheBeforeRelease);
|
||||
expect(screen.queryByText("Runtime secrets saved. Stored values remain hidden.")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("a deferred secret save cannot update a context after its secret permission changes", async () => {
|
||||
const user = userEvent.setup();
|
||||
let release!: () => void;
|
||||
let started!: () => void;
|
||||
let settled!: () => void;
|
||||
const held = new Promise<void>((resolve) => { release = resolve; });
|
||||
const requestStarted = new Promise<void>((resolve) => { started = resolve; });
|
||||
const requestSettled = new Promise<void>((resolve) => { settled = resolve; });
|
||||
server.use(http.put("/api/workspaces/psd-clinical/secrets", async () => {
|
||||
started();
|
||||
try {
|
||||
await held;
|
||||
return HttpResponse.json(runtimeConfiguration(true));
|
||||
} finally {
|
||||
settled();
|
||||
}
|
||||
}));
|
||||
const manager = renderManagerWithSettings();
|
||||
await user.click(await screen.findByRole("button", { name: "PSD Clinical" }));
|
||||
await user.type(await screen.findByLabelText("Data warehouse password"), "one-time-password");
|
||||
await user.click(screen.getByRole("button", { name: "Save entered secrets" }));
|
||||
await requestStarted;
|
||||
|
||||
manager.rerender({ canManageSecrets: false });
|
||||
manager.rerender({ canManageSecrets: true });
|
||||
await user.type(await screen.findByLabelText("Data warehouse password"), "new-secret");
|
||||
expect(await screen.findByRole("button", { name: "Save entered secrets" })).not.toBeDisabled();
|
||||
const cacheBeforeRelease = manager.client.getQueryData(["workspace-runtime-configuration", "psd-clinical"]);
|
||||
|
||||
act(() => release());
|
||||
await act(async () => {
|
||||
await requestSettled;
|
||||
await new Promise((resolve) => { setTimeout(resolve, 50); });
|
||||
});
|
||||
|
||||
expect(manager.client.getQueryData(["workspace-runtime-configuration", "psd-clinical"])).toEqual(cacheBeforeRelease);
|
||||
expect(screen.queryByText("Runtime secrets saved. Stored values remain hidden.")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("a deferred repository update cannot update a reopened dialog or refetch its cache", async () => {
|
||||
const user = userEvent.setup();
|
||||
let release!: () => void;
|
||||
let started!: () => void;
|
||||
let settled!: () => void;
|
||||
let statusRequests = 0;
|
||||
let workspaceRequests = 0;
|
||||
const held = new Promise<void>((resolve) => { release = resolve; });
|
||||
const requestStarted = new Promise<void>((resolve) => { started = resolve; });
|
||||
const requestSettled = new Promise<void>((resolve) => { settled = resolve; });
|
||||
server.use(
|
||||
http.get("/api/workspace-registry/status", () => {
|
||||
statusRequests++;
|
||||
return HttpResponse.json({ branch: "main", head: "a".repeat(40), ahead: 0, behind: 0, degraded: false });
|
||||
}),
|
||||
http.get("/api/workspaces", () => {
|
||||
workspaceRequests++;
|
||||
return HttpResponse.json([workspaceSummaryFixture("psd-clinical", {
|
||||
displayName: "PSD Clinical", description: "Clinical data", configurationState: "configuration_required", revision,
|
||||
})]);
|
||||
}),
|
||||
http.post("/api/workspace-registry/pull", async () => {
|
||||
started();
|
||||
try {
|
||||
await held;
|
||||
return HttpResponse.json({ branch: "main", head: "b".repeat(40), ahead: 0, behind: 0, degraded: false });
|
||||
} finally {
|
||||
settled();
|
||||
}
|
||||
}),
|
||||
);
|
||||
const manager = renderManagerWithSettings();
|
||||
await user.click(await screen.findByRole("button", { name: "Update workspace repository" }));
|
||||
await requestStarted;
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Close workspace management" }));
|
||||
manager.rerender({ open: false });
|
||||
manager.rerender({ open: true });
|
||||
expect(await screen.findByRole("button", { name: "Update workspace repository" })).not.toBeDisabled();
|
||||
const requestsBeforeRelease = { statusRequests, workspaceRequests };
|
||||
|
||||
act(() => release());
|
||||
await act(async () => {
|
||||
await requestSettled;
|
||||
await new Promise((resolve) => { setTimeout(resolve, 50); });
|
||||
});
|
||||
|
||||
expect({ statusRequests, workspaceRequests }).toEqual(requestsBeforeRelease);
|
||||
expect(screen.queryByText("Workspace repository updated and validated.")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("a deferred repository update cannot update a context after a cross-workspace transition", async () => {
|
||||
const user = userEvent.setup();
|
||||
const alternateId = "other-clinical";
|
||||
const alternateWorkspace = canonicalWorkspaceFixture(alternateId);
|
||||
const alternateRevision = workspaceRevisionFixture(alternateId);
|
||||
let release!: () => void;
|
||||
let started!: () => void;
|
||||
let settled!: () => void;
|
||||
let statusRequests = 0;
|
||||
let workspaceRequests = 0;
|
||||
const held = new Promise<void>((resolve) => { release = resolve; });
|
||||
const requestStarted = new Promise<void>((resolve) => { started = resolve; });
|
||||
const requestSettled = new Promise<void>((resolve) => { settled = resolve; });
|
||||
server.use(
|
||||
http.get("/api/workspace-registry/status", () => {
|
||||
statusRequests++;
|
||||
return HttpResponse.json({ branch: "main", head: "a".repeat(40), ahead: 0, behind: 0, degraded: false });
|
||||
}),
|
||||
http.get("/api/workspaces", () => {
|
||||
workspaceRequests++;
|
||||
return HttpResponse.json([
|
||||
workspaceSummaryFixture("psd-clinical", {
|
||||
displayName: "PSD Clinical", description: "Clinical data", configurationState: "configuration_required", revision,
|
||||
}),
|
||||
workspaceSummaryFixture(alternateId, {
|
||||
displayName: "Other Clinical", description: "Other data", configurationState: "configuration_required", revision: alternateRevision,
|
||||
}),
|
||||
]);
|
||||
}),
|
||||
http.get(`/api/workspaces/${alternateId}`, () => HttpResponse.json({ workspace: alternateWorkspace, revision: alternateRevision })),
|
||||
http.get(`/api/workspaces/${alternateId}/runtime-configuration`, () => HttpResponse.json({
|
||||
workspaceId: alternateId, revision: alternateRevision, configurationState: "configuration_required", requirements: [{ ...requirement }],
|
||||
})),
|
||||
http.post("/api/workspace-registry/pull", async () => {
|
||||
started();
|
||||
try {
|
||||
await held;
|
||||
return HttpResponse.json({ branch: "main", head: "b".repeat(40), ahead: 0, behind: 0, degraded: false });
|
||||
} finally {
|
||||
settled();
|
||||
}
|
||||
}),
|
||||
);
|
||||
const manager = renderManagerWithSettings();
|
||||
await user.click(await screen.findByRole("button", { name: "Update workspace repository" }));
|
||||
await requestStarted;
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Other Clinical" }));
|
||||
expect(await screen.findByRole("heading", { name: "Other Clinical" })).toBeVisible();
|
||||
await user.click(within(screen.getByRole("navigation", { name: "Workspaces" }))
|
||||
.getByRole("button", { name: "Back to Level 1" }));
|
||||
expect(await screen.findByRole("button", { name: "Update workspace repository" })).not.toBeDisabled();
|
||||
const requestsBeforeRelease = { statusRequests, workspaceRequests };
|
||||
|
||||
act(() => release());
|
||||
await act(async () => {
|
||||
await requestSettled;
|
||||
await new Promise((resolve) => { setTimeout(resolve, 50); });
|
||||
});
|
||||
|
||||
expect({ statusRequests, workspaceRequests }).toEqual(requestsBeforeRelease);
|
||||
expect(screen.queryByText("Workspace repository updated and validated.")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("a deferred repository update cannot update a context after workspace permission changes", async () => {
|
||||
const user = userEvent.setup();
|
||||
let release!: () => void;
|
||||
let started!: () => void;
|
||||
let settled!: () => void;
|
||||
let statusRequests = 0;
|
||||
let workspaceRequests = 0;
|
||||
const held = new Promise<void>((resolve) => { release = resolve; });
|
||||
const requestStarted = new Promise<void>((resolve) => { started = resolve; });
|
||||
const requestSettled = new Promise<void>((resolve) => { settled = resolve; });
|
||||
server.use(
|
||||
http.get("/api/workspace-registry/status", () => {
|
||||
statusRequests++;
|
||||
return HttpResponse.json({ branch: "main", head: "a".repeat(40), ahead: 0, behind: 0, degraded: false });
|
||||
}),
|
||||
http.get("/api/workspaces", () => {
|
||||
workspaceRequests++;
|
||||
return HttpResponse.json([workspaceSummaryFixture("psd-clinical", {
|
||||
displayName: "PSD Clinical", description: "Clinical data", configurationState: "configuration_required", revision,
|
||||
})]);
|
||||
}),
|
||||
http.post("/api/workspace-registry/pull", async () => {
|
||||
started();
|
||||
try {
|
||||
await held;
|
||||
return HttpResponse.json({ branch: "main", head: "b".repeat(40), ahead: 0, behind: 0, degraded: false });
|
||||
} finally {
|
||||
settled();
|
||||
}
|
||||
}),
|
||||
);
|
||||
const manager = renderManagerWithSettings();
|
||||
await user.click(await screen.findByRole("button", { name: "Update workspace repository" }));
|
||||
await requestStarted;
|
||||
|
||||
manager.rerender({ canManageWorkspace: false });
|
||||
manager.rerender({ canManageWorkspace: true });
|
||||
expect(await screen.findByRole("button", { name: "Update workspace repository" })).not.toBeDisabled();
|
||||
const requestsBeforeRelease = { statusRequests, workspaceRequests };
|
||||
|
||||
act(() => release());
|
||||
await act(async () => {
|
||||
await requestSettled;
|
||||
await new Promise((resolve) => { setTimeout(resolve, 50); });
|
||||
});
|
||||
|
||||
expect({ statusRequests, workspaceRequests }).toEqual(requestsBeforeRelease);
|
||||
expect(screen.queryByText("Workspace repository updated and validated.")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("a deferred validation cannot update a context after workspace permission changes", async () => {
|
||||
const user = userEvent.setup();
|
||||
let release!: () => void;
|
||||
let started!: () => void;
|
||||
let settled!: () => void;
|
||||
const held = new Promise<void>((resolve) => { release = resolve; });
|
||||
const requestStarted = new Promise<void>((resolve) => { started = resolve; });
|
||||
const requestSettled = new Promise<void>((resolve) => { settled = resolve; });
|
||||
server.use(http.post("/api/workspaces/validate", async () => {
|
||||
started();
|
||||
try {
|
||||
await held;
|
||||
return HttpResponse.json({ workspace, contract: {}, activatable: true, diagnostics: [], authentication: readyAuthentication });
|
||||
} finally {
|
||||
settled();
|
||||
}
|
||||
}));
|
||||
const manager = renderManagerWithSettings();
|
||||
await user.click(await screen.findByRole("button", { name: "PSD Clinical" }));
|
||||
await user.click(screen.getByRole("button", { name: "Validate workspace source" }));
|
||||
await requestStarted;
|
||||
|
||||
manager.rerender({ canManageWorkspace: false });
|
||||
manager.rerender({ canManageWorkspace: true });
|
||||
expect(await screen.findByRole("button", { name: "Validate workspace source" })).not.toBeDisabled();
|
||||
|
||||
act(() => release());
|
||||
await act(async () => {
|
||||
await requestSettled;
|
||||
await new Promise((resolve) => { setTimeout(resolve, 50); });
|
||||
});
|
||||
|
||||
expect(screen.queryByTestId("workspace-authentication")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("a deferred connection test cannot update a context after workspace permission changes", async () => {
|
||||
const user = userEvent.setup();
|
||||
let release!: () => void;
|
||||
let started!: () => void;
|
||||
let settled!: () => void;
|
||||
const held = new Promise<void>((resolve) => { release = resolve; });
|
||||
const requestStarted = new Promise<void>((resolve) => { started = resolve; });
|
||||
const requestSettled = new Promise<void>((resolve) => { settled = resolve; });
|
||||
server.use(http.post("/api/workspaces/psd-clinical/test", async () => {
|
||||
started();
|
||||
try {
|
||||
await held;
|
||||
return HttpResponse.json({ activatable: true, diagnostics: [], authentication: readyAuthentication });
|
||||
} finally {
|
||||
settled();
|
||||
}
|
||||
}));
|
||||
const manager = renderManagerWithSettings();
|
||||
await user.click(await screen.findByRole("button", { name: "PSD Clinical" }));
|
||||
await user.click(screen.getByRole("button", { name: "Test workspace connections" }));
|
||||
await requestStarted;
|
||||
|
||||
manager.rerender({ canManageWorkspace: false });
|
||||
manager.rerender({ canManageWorkspace: true });
|
||||
expect(await screen.findByRole("button", { name: "Test workspace connections" })).not.toBeDisabled();
|
||||
|
||||
act(() => release());
|
||||
await act(async () => {
|
||||
await requestSettled;
|
||||
await new Promise((resolve) => { setTimeout(resolve, 50); });
|
||||
});
|
||||
|
||||
expect(screen.queryByTestId("workspace-authentication")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("closing clears unsaved secret fields", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClose = vi.fn();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
AlertCircle,
|
||||
@@ -26,7 +26,12 @@ import {
|
||||
type AuthDiagnostics,
|
||||
type WorkspaceRuntimeConfiguration,
|
||||
} from "../api/workspaces";
|
||||
import { captureAuthOperation, isAuthOperationCurrent, StaleAuthOperationError } from "../auth/authOperation";
|
||||
import {
|
||||
captureAuthOperation,
|
||||
isAuthOperationCurrent,
|
||||
StaleAuthOperationError,
|
||||
type AuthOperationGuard,
|
||||
} from "../auth/authOperation";
|
||||
import { Button } from "../components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -62,6 +67,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";
|
||||
|
||||
type WorkspaceOperationContext = Readonly<{
|
||||
open: boolean;
|
||||
canManageWorkspace: boolean;
|
||||
canManageSecrets: boolean;
|
||||
}>;
|
||||
|
||||
type WorkspaceOperationGuard = Readonly<{
|
||||
operation: AuthOperationGuard;
|
||||
context: WorkspaceOperationContext;
|
||||
}>;
|
||||
|
||||
export function WorkspaceManager({
|
||||
open,
|
||||
onClose,
|
||||
@@ -87,50 +103,97 @@ export function WorkspaceManager({
|
||||
const operationEpochRef = useRef(0);
|
||||
const diagnosticEpochRef = useRef(0);
|
||||
const selectedIdRef = useRef(selectedId);
|
||||
const wasOpenRef = useRef(open);
|
||||
const contextRef = useRef<WorkspaceOperationContext>({ open, canManageWorkspace, canManageSecrets });
|
||||
const committedContextRef = useRef<WorkspaceOperationContext>(contextRef.current);
|
||||
selectedIdRef.current = selectedId;
|
||||
contextRef.current = { open, canManageWorkspace, canManageSecrets };
|
||||
|
||||
const clearGlobalMessages = () => {
|
||||
const clearWorkspacePresentation = () => {
|
||||
setNotice(undefined);
|
||||
setDiagnostics([]);
|
||||
};
|
||||
|
||||
const clearDiagnosticMessages = () => {
|
||||
diagnosticEpochRef.current += 1;
|
||||
setValidationNotice(undefined);
|
||||
setValidationDiagnostics([]);
|
||||
setConnectionNotice(undefined);
|
||||
setConnectionDiagnostics([]);
|
||||
setAuthentication(undefined);
|
||||
setBusyAction((current) => current === "validate" || current === "test" ? undefined : current);
|
||||
};
|
||||
|
||||
const clearMessages = () => {
|
||||
clearGlobalMessages();
|
||||
clearDiagnosticMessages();
|
||||
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"] });
|
||||
};
|
||||
|
||||
useEffect(() => () => { operationEpochRef.current += 1; }, []);
|
||||
useEffect(() => {
|
||||
if (wasOpenRef.current && !open) {
|
||||
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({});
|
||||
clearMessages();
|
||||
}
|
||||
wasOpenRef.current = open;
|
||||
}, [open]);
|
||||
clearWorkspacePresentation();
|
||||
};
|
||||
|
||||
async function guardedQuery<T>(request: () => Promise<T>, targetId?: string): Promise<T> {
|
||||
const guard = captureAuthOperation({
|
||||
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 currentSessionId = targetId === undefined ? null : selectedIdRef.current;
|
||||
if (!isAuthOperationCurrent(guard, {
|
||||
sessionId: currentSessionId,
|
||||
disposalEpoch: operationEpochRef.current,
|
||||
})) throw new StaleAuthOperationError();
|
||||
const currentTargetId = targetId === undefined ? undefined : selectedIdRef.current;
|
||||
if (!isWorkspaceOperationCurrent(guard, currentTargetId)) throw new StaleAuthOperationError();
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -161,90 +224,84 @@ export function WorkspaceManager({
|
||||
});
|
||||
|
||||
const close = () => {
|
||||
setSecretValues({});
|
||||
clearMessages();
|
||||
invalidateWorkspaceContext();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const selectWorkspace = (id: string) => {
|
||||
invalidateWorkspaceContext();
|
||||
setSelectedId(id);
|
||||
setSecretValues({});
|
||||
clearMessages();
|
||||
};
|
||||
|
||||
const showLevelOne = () => {
|
||||
invalidateWorkspaceContext();
|
||||
setSelectedId(undefined);
|
||||
setSecretValues({});
|
||||
clearMessages();
|
||||
};
|
||||
|
||||
async function updateRepository() {
|
||||
const guard = captureAuthOperation({ disposalEpoch: operationEpochRef.current });
|
||||
invalidateWorkspaceContext({ clearCaches: true });
|
||||
const guard = captureWorkspaceOperation();
|
||||
if (!guard) return;
|
||||
clearMessages();
|
||||
const targetId = selectedIdRef.current;
|
||||
setBusyAction("repository");
|
||||
try {
|
||||
await pullWorkspaceRegistry();
|
||||
if (!isAuthOperationCurrent(guard, { disposalEpoch: operationEpochRef.current })) return;
|
||||
if (!isWorkspaceOperationCurrent(guard)) return;
|
||||
await Promise.all([
|
||||
statusQuery.refetch(),
|
||||
workspacesQuery.refetch(),
|
||||
selectedId ? detailQuery.refetch() : Promise.resolve(),
|
||||
selectedId ? runtimeQuery.refetch() : Promise.resolve(),
|
||||
targetId ? detailQuery.refetch() : Promise.resolve(),
|
||||
targetId ? runtimeQuery.refetch() : Promise.resolve(),
|
||||
]);
|
||||
if (!isAuthOperationCurrent(guard, { disposalEpoch: operationEpochRef.current })) return;
|
||||
if (!isWorkspaceOperationCurrent(guard)) return;
|
||||
setNotice("Workspace repository updated and validated.");
|
||||
} catch (error) {
|
||||
if (isAuthOperationCurrent(guard, { disposalEpoch: operationEpochRef.current })) {
|
||||
if (isWorkspaceOperationCurrent(guard)) {
|
||||
setDiagnostics([publicError(error, "git_unavailable: Workspace repository could not be updated")]);
|
||||
}
|
||||
} finally {
|
||||
if (isAuthOperationCurrent(guard, { disposalEpoch: operationEpochRef.current })) setBusyAction(undefined);
|
||||
if (isWorkspaceOperationCurrent(guard)) setBusyAction(undefined);
|
||||
}
|
||||
}
|
||||
|
||||
async function validateSource() {
|
||||
if (!detailQuery.data) return;
|
||||
const guard = captureAuthOperation({ sessionId: selectedId, disposalEpoch: operationEpochRef.current });
|
||||
const targetId = selectedId;
|
||||
const source = detailQuery.data.workspace;
|
||||
invalidateWorkspaceContext({ clearSecrets: false });
|
||||
const guard = captureWorkspaceOperation(targetId);
|
||||
if (!guard) return;
|
||||
const diagnosticEpoch = ++diagnosticEpochRef.current;
|
||||
const diagnosticEpoch = diagnosticEpochRef.current;
|
||||
setBusyAction("validate");
|
||||
clearGlobalMessages();
|
||||
setAuthentication(undefined);
|
||||
setValidationNotice(undefined);
|
||||
setValidationDiagnostics([]);
|
||||
try {
|
||||
const result = await validateWorkspace(detailQuery.data.workspace);
|
||||
const result = await validateWorkspace(source);
|
||||
if (diagnosticEpoch !== diagnosticEpochRef.current ||
|
||||
!isAuthOperationCurrent(guard, { sessionId: selectedIdRef.current, disposalEpoch: operationEpochRef.current })) return;
|
||||
!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 &&
|
||||
isAuthOperationCurrent(guard, { sessionId: selectedIdRef.current, disposalEpoch: operationEpochRef.current })) {
|
||||
isWorkspaceOperationCurrent(guard, selectedIdRef.current)) {
|
||||
setValidationDiagnostics([publicError(error, "workspace_invalid: Workspace validation could not be completed")]);
|
||||
}
|
||||
} finally {
|
||||
if (diagnosticEpoch === diagnosticEpochRef.current &&
|
||||
isAuthOperationCurrent(guard, { sessionId: selectedIdRef.current, disposalEpoch: operationEpochRef.current })) setBusyAction(undefined);
|
||||
isWorkspaceOperationCurrent(guard, selectedIdRef.current)) setBusyAction(undefined);
|
||||
}
|
||||
}
|
||||
|
||||
async function testConnections() {
|
||||
if (!selectedId) return;
|
||||
const targetId = selectedId;
|
||||
const guard = captureAuthOperation({ sessionId: targetId, disposalEpoch: operationEpochRef.current });
|
||||
invalidateWorkspaceContext({ clearSecrets: false });
|
||||
const guard = captureWorkspaceOperation(targetId);
|
||||
if (!guard) return;
|
||||
const diagnosticEpoch = ++diagnosticEpochRef.current;
|
||||
const diagnosticEpoch = diagnosticEpochRef.current;
|
||||
setBusyAction("test");
|
||||
clearGlobalMessages();
|
||||
setAuthentication(undefined);
|
||||
setConnectionNotice(undefined);
|
||||
setConnectionDiagnostics([]);
|
||||
try {
|
||||
const result = await testWorkspace(selectedId);
|
||||
const result = await testWorkspace(targetId);
|
||||
if (diagnosticEpoch !== diagnosticEpochRef.current ||
|
||||
!isAuthOperationCurrent(guard, { sessionId: selectedIdRef.current, disposalEpoch: operationEpochRef.current })) return;
|
||||
!isWorkspaceOperationCurrent(guard, selectedIdRef.current)) return;
|
||||
setAuthentication(result.authentication);
|
||||
const issues = result.diagnostics.filter(({ level }) => level !== "info");
|
||||
const informational = result.diagnostics.find(({ level }) => level === "info");
|
||||
@@ -258,70 +315,70 @@ export function WorkspaceManager({
|
||||
}
|
||||
} catch (error) {
|
||||
if (diagnosticEpoch === diagnosticEpochRef.current &&
|
||||
isAuthOperationCurrent(guard, { sessionId: selectedIdRef.current, disposalEpoch: operationEpochRef.current })) {
|
||||
isWorkspaceOperationCurrent(guard, selectedIdRef.current)) {
|
||||
setConnectionDiagnostics([publicError(error, "connector_unavailable: Workspace connections could not be tested")]);
|
||||
}
|
||||
} finally {
|
||||
if (diagnosticEpoch === diagnosticEpochRef.current &&
|
||||
isAuthOperationCurrent(guard, { sessionId: selectedIdRef.current, disposalEpoch: operationEpochRef.current })) setBusyAction(undefined);
|
||||
isWorkspaceOperationCurrent(guard, selectedIdRef.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),
|
||||
);
|
||||
if (Object.keys(values).length === 0) return;
|
||||
invalidateWorkspaceContext({ clearSecrets: false });
|
||||
const guard = captureWorkspaceOperation(targetId);
|
||||
if (!guard) return;
|
||||
setBusyAction("save-secrets");
|
||||
clearMessages();
|
||||
try {
|
||||
const configuration = await saveWorkspaceSecrets(targetId, values);
|
||||
if (!isAuthOperationCurrent(guard, { sessionId: selectedIdRef.current, disposalEpoch: operationEpochRef.current })) return;
|
||||
if (!isWorkspaceOperationCurrent(guard, selectedIdRef.current)) return;
|
||||
queryClient.setQueryData(
|
||||
["workspace-runtime-configuration", targetId],
|
||||
configuration,
|
||||
);
|
||||
setSecretValues({});
|
||||
await workspacesQuery.refetch();
|
||||
if (!isAuthOperationCurrent(guard, { sessionId: selectedIdRef.current, disposalEpoch: operationEpochRef.current })) return;
|
||||
if (!isWorkspaceOperationCurrent(guard, selectedIdRef.current)) return;
|
||||
setNotice("Runtime secrets saved. Stored values remain hidden.");
|
||||
} catch (error) {
|
||||
if (isAuthOperationCurrent(guard, { sessionId: selectedIdRef.current, disposalEpoch: operationEpochRef.current })) {
|
||||
if (isWorkspaceOperationCurrent(guard, selectedIdRef.current)) {
|
||||
setDiagnostics([publicError(error, "workspace_invalid: Runtime secrets could not be saved")]);
|
||||
}
|
||||
} finally {
|
||||
if (isAuthOperationCurrent(guard, { sessionId: selectedIdRef.current, disposalEpoch: operationEpochRef.current })) setBusyAction(undefined);
|
||||
if (isWorkspaceOperationCurrent(guard, selectedIdRef.current)) setBusyAction(undefined);
|
||||
}
|
||||
}
|
||||
|
||||
async function forgetSecret(requirementId: string) {
|
||||
if (!selectedId) return;
|
||||
const targetId = selectedId;
|
||||
const guard = captureAuthOperation({ sessionId: targetId, disposalEpoch: operationEpochRef.current });
|
||||
invalidateWorkspaceContext({ clearSecrets: false });
|
||||
const guard = captureWorkspaceOperation(targetId);
|
||||
if (!guard) return;
|
||||
setBusyAction(`forget:${requirementId}`);
|
||||
clearMessages();
|
||||
try {
|
||||
const configuration = await forgetWorkspaceSecret(targetId, requirementId);
|
||||
if (!isAuthOperationCurrent(guard, { sessionId: selectedIdRef.current, disposalEpoch: operationEpochRef.current })) return;
|
||||
if (!isWorkspaceOperationCurrent(guard, selectedIdRef.current)) return;
|
||||
queryClient.setQueryData(
|
||||
["workspace-runtime-configuration", targetId],
|
||||
configuration,
|
||||
);
|
||||
setSecretValues((current) => ({ ...current, [requirementId]: "" }));
|
||||
await workspacesQuery.refetch();
|
||||
if (!isAuthOperationCurrent(guard, { sessionId: selectedIdRef.current, disposalEpoch: operationEpochRef.current })) return;
|
||||
if (!isWorkspaceOperationCurrent(guard, selectedIdRef.current)) return;
|
||||
setNotice("Stored secret forgotten.");
|
||||
} catch (error) {
|
||||
if (isAuthOperationCurrent(guard, { sessionId: selectedIdRef.current, disposalEpoch: operationEpochRef.current })) {
|
||||
if (isWorkspaceOperationCurrent(guard, selectedIdRef.current)) {
|
||||
setDiagnostics([publicError(error, "workspace_invalid: Stored secret could not be forgotten")]);
|
||||
}
|
||||
} finally {
|
||||
if (isAuthOperationCurrent(guard, { sessionId: selectedIdRef.current, disposalEpoch: operationEpochRef.current })) setBusyAction(undefined);
|
||||
if (isWorkspaceOperationCurrent(guard, selectedIdRef.current)) setBusyAction(undefined);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user