Skip to content

refactor: poll for git auth updates when creating a workspace #9804

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 13 commits into from
Sep 26, 2023
13 changes: 13 additions & 0 deletions site/src/api/queries/templates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,3 +120,16 @@ export const updateActiveTemplateVersion = (
},
};
};

export const templateVersionGitAuthKey = (versionId: string) => [
"templateVersion",
versionId,
"gitAuth",
];

export const templateVersionGitAuth = (versionId: string) => {
return {
queryKey: templateVersionGitAuthKey(versionId),
queryFn: () => API.getTemplateVersionGitAuth(versionId),
};
};
88 changes: 74 additions & 14 deletions site/src/pages/CreateWorkspacePage/CreateWorkspacePage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,13 @@ import {
MockWorkspace,
MockWorkspaceQuota,
MockWorkspaceRequest,
MockWorkspaceRichParametersRequest,
MockTemplateVersionParameter1,
MockTemplateVersionParameter2,
MockTemplateVersionParameter3,
MockTemplateVersionGitAuth,
MockOrganization,
MockTemplateVersionGitAuthAuthenticated,
} from "testHelpers/entities";
import {
renderWithAuth,
Expand All @@ -31,17 +33,6 @@ const renderCreateWorkspacePage = () => {
});
};

Object.defineProperty(window, "BroadcastChannel", {
value: class {
addEventListener() {
// noop
}
close() {
// noop
}
},
});

describe("CreateWorkspacePage", () => {
it("succeeds with default owner", async () => {
jest
Expand Down Expand Up @@ -71,9 +62,9 @@ describe("CreateWorkspacePage", () => {
expect(API.createWorkspace).toBeCalledWith(
MockUser.organization_ids[0],
MockUser.id,
{
...MockWorkspaceRequest,
},
expect.objectContaining({
...MockWorkspaceRichParametersRequest,
}),
),
);
});
Expand Down Expand Up @@ -165,6 +156,50 @@ describe("CreateWorkspacePage", () => {
expect(validationError).toBeInTheDocument();
});

it("gitauth authenticates and succeeds", async () => {
jest
.spyOn(API, "getWorkspaceQuota")
.mockResolvedValueOnce(MockWorkspaceQuota);
jest
.spyOn(API, "getUsers")
.mockResolvedValueOnce({ users: [MockUser], count: 1 });
jest.spyOn(API, "createWorkspace").mockResolvedValueOnce(MockWorkspace);
jest
.spyOn(API, "getTemplateVersionGitAuth")
.mockResolvedValue([MockTemplateVersionGitAuth]);

renderCreateWorkspacePage();
await waitForLoaderToBeRemoved();

const nameField = await screen.findByLabelText(nameLabelText);
// have to use fireEvent b/c userEvent isn't cleaning up properly between tests
fireEvent.change(nameField, {
target: { value: "test" },
});

const githubButton = await screen.findByText("Login with GitHub");
await userEvent.click(githubButton);

jest
.spyOn(API, "getTemplateVersionGitAuth")
.mockResolvedValue([MockTemplateVersionGitAuthAuthenticated]);

await screen.findByText("Authenticated with GitHub");

const submitButton = screen.getByText(createWorkspaceText);
await userEvent.click(submitButton);

await waitFor(() =>
expect(API.createWorkspace).toBeCalledWith(
MockUser.organization_ids[0],
MockUser.id,
expect.objectContaining({
...MockWorkspaceRequest,
}),
),
);
});

it("gitauth: errors if unauthenticated and submits", async () => {
jest
.spyOn(API, "getTemplateVersionGitAuth")
Expand Down Expand Up @@ -210,4 +245,29 @@ describe("CreateWorkspacePage", () => {
);
});
});

it("auto create a workspace if uses mode=auto and version=version-id", async () => {
const param = "first_parameter";
const paramValue = "It works!";
const createWorkspaceSpy = jest.spyOn(API, "createWorkspace");

renderWithAuth(<CreateWorkspacePage />, {
route:
"/templates/" +
MockTemplate.name +
`/workspace?param.${param}=${paramValue}&mode=auto&version=test-template-version`,
path: "/templates/:template/workspace",
});

await waitFor(() => {
expect(createWorkspaceSpy).toBeCalledWith(
MockOrganization.id,
"me",
expect.objectContaining({
template_version_id: MockTemplate.active_version_id,
rich_parameter_values: [{ name: param, value: paramValue }],
}),
);
});
});
});
67 changes: 51 additions & 16 deletions site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,11 @@
import { useMachine } from "@xstate/react";
import {
Template,
TemplateVersionGitAuth,
TemplateVersionParameter,
WorkspaceBuildParameter,
} from "api/typesGenerated";
import { useMe } from "hooks/useMe";
import { useOrganizationId } from "hooks/useOrganizationId";
import { FC } from "react";
import { type FC, useCallback, useState, useEffect } from "react";
import { Helmet } from "react-helmet-async";
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
import { pageTitle } from "utils/page";
Expand All @@ -25,6 +23,10 @@ import {
colors,
NumberDictionary,
} from "unique-names-generator";
import { useQuery } from "@tanstack/react-query";
import { templateVersionGitAuth } from "api/queries/templates";

export type GitAuthPollingState = "idle" | "polling" | "abandoned";

const CreateWorkspacePage: FC = () => {
const organizationId = useOrganizationId();
Expand All @@ -50,18 +52,49 @@ const CreateWorkspacePage: FC = () => {
},
},
});
const {
template,
error,
parameters,
permissions,
gitAuth,
defaultName,
versionId,
} = createWorkspaceState.context;
const { template, parameters, permissions, defaultName, versionId } =
createWorkspaceState.context;
const title = createWorkspaceState.matches("autoCreating")
? "Creating workspace..."
: "Create Workspace";
: "Create workspace";

const [gitAuthPollingState, setGitAuthPollingState] =
useState<GitAuthPollingState>("idle");

const startPollingGitAuth = useCallback(() => {
setGitAuthPollingState("polling");
}, []);

const { data: gitAuth, error } = useQuery(
versionId
? {
...templateVersionGitAuth(versionId),
refetchInterval: gitAuthPollingState === "polling" ? 1000 : false,
}
: { enabled: false },
);

const allSignedIn = gitAuth?.every((it) => it.authenticated);

useEffect(() => {
if (allSignedIn) {
setGitAuthPollingState("idle");
return;
}

if (gitAuthPollingState !== "polling") {
return;
}

// Poll for a maximum of one minute
const quitPolling = setTimeout(
() => setGitAuthPollingState("abandoned"),
60_000,
);
return () => {
clearTimeout(quitPolling);
};
}, [gitAuthPollingState, allSignedIn]);

return (
<>
Expand All @@ -81,11 +114,13 @@ const CreateWorkspacePage: FC = () => {
defaultOwner={me}
defaultBuildParameters={defaultBuildParameters}
error={error}
template={template as Template}
template={template!}
versionId={versionId}
gitAuth={gitAuth as TemplateVersionGitAuth[]}
gitAuth={gitAuth ?? []}
gitAuthPollingState={gitAuthPollingState}
startPollingGitAuth={startPollingGitAuth}
permissions={permissions as CreateWSPermissions}
parameters={parameters as TemplateVersionParameter[]}
parameters={parameters!}
creatingWorkspace={createWorkspaceState.matches("creatingWorkspace")}
onCancel={() => {
navigate(-1);
Expand Down
19 changes: 11 additions & 8 deletions site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import { CreateWSPermissions } from "xServices/createWorkspace/createWorkspaceXS
import { GitAuth } from "./GitAuth";
import { ErrorAlert } from "components/Alert/ErrorAlert";
import { Stack } from "components/Stack/Stack";
import { type GitAuthPollingState } from "./CreateWorkspacePage";

export interface CreateWorkspacePageViewProps {
error: unknown;
Expand All @@ -38,6 +39,8 @@ export interface CreateWorkspacePageViewProps {
template: TypesGen.Template;
versionId?: string;
gitAuth: TypesGen.TemplateVersionGitAuth[];
gitAuthPollingState: GitAuthPollingState;
startPollingGitAuth: () => void;
parameters: TypesGen.TemplateVersionParameter[];
defaultBuildParameters: TypesGen.WorkspaceBuildParameter[];
permissions: CreateWSPermissions;
Expand All @@ -56,6 +59,8 @@ export const CreateWorkspacePageView: FC<CreateWorkspacePageViewProps> = ({
template,
versionId,
gitAuth,
gitAuthPollingState,
startPollingGitAuth,
parameters,
defaultBuildParameters,
permissions,
Expand Down Expand Up @@ -113,7 +118,7 @@ export const CreateWorkspacePageView: FC<CreateWorkspacePageViewProps> = ({
>
<FormFields>
<SelectedTemplate template={template} />
{versionId && (
{versionId && versionId !== template.active_version_id && (
<Stack spacing={1} className={styles.hasDescription}>
<TextField
disabled
Expand Down Expand Up @@ -161,11 +166,13 @@ export const CreateWorkspacePageView: FC<CreateWorkspacePageViewProps> = ({
description="This template requires authentication to automatically perform Git operations on create."
>
<FormFields>
{gitAuth.map((auth, index) => (
{gitAuth.map((auth) => (
<GitAuth
key={index}
key={auth.id}
authenticateURL={auth.authenticate_url}
authenticated={auth.authenticated}
gitAuthPollingState={gitAuthPollingState}
startPollingGitAuth={startPollingGitAuth}
type={auth.type}
error={gitAuthErrors[auth.id]}
/>
Expand Down Expand Up @@ -229,12 +236,8 @@ type GitAuthErrors = Record<string, string>;
const useGitAuthVerification = (gitAuth: TypesGen.TemplateVersionGitAuth[]) => {
const [gitAuthErrors, setGitAuthErrors] = useState<GitAuthErrors>({});

// Clear errors when gitAuth is refreshed
useEffect(() => {
// templateGitAuth is refreshed automatically using a BroadcastChannel
// which may change the `authenticated` property.
//
// If the provider becomes authenticated, we want the error message
// to disappear.
setGitAuthErrors({});
}, [gitAuth]);

Expand Down
45 changes: 30 additions & 15 deletions site/src/pages/CreateWorkspacePage/GitAuth.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,20 +9,30 @@ import { BitbucketIcon } from "components/Icons/BitbucketIcon";
import { GitlabIcon } from "components/Icons/GitlabIcon";
import { FC } from "react";
import { makeStyles } from "@mui/styles";
import { type GitAuthPollingState } from "./CreateWorkspacePage";
import { Stack } from "components/Stack/Stack";
import ReplayIcon from "@mui/icons-material/Replay";
import { LoadingButton } from "components/LoadingButton/LoadingButton";

export interface GitAuthProps {
type: TypesGen.GitProvider;
authenticated: boolean;
authenticateURL: string;
gitAuthPollingState: GitAuthPollingState;
startPollingGitAuth: () => void;
error?: string;
}

export const GitAuth: FC<GitAuthProps> = ({
type,
authenticated,
authenticateURL,
error,
}) => {
export const GitAuth: FC<GitAuthProps> = (props) => {
const {
type,
authenticated,
authenticateURL,
gitAuthPollingState,
startPollingGitAuth,
error,
} = props;

const styles = useStyles({
error: typeof error !== "undefined",
});
Expand Down Expand Up @@ -52,12 +62,11 @@ export const GitAuth: FC<GitAuthProps> = ({

return (
<Tooltip
title={
authenticated ? "You're already authenticated! No action needed." : ``
}
title={authenticated && `${prettyName} has already been connected.`}
>
<div>
<Button
<Stack alignItems="center" spacing={1}>
<LoadingButton
loading={gitAuthPollingState === "polling"}
href={authenticateURL}
variant="contained"
size="large"
Expand All @@ -73,15 +82,21 @@ export const GitAuth: FC<GitAuthProps> = ({
return;
}
window.open(authenticateURL, "_blank", "width=900,height=600");
startPollingGitAuth();
}}
>
{authenticated
? `You're authenticated with ${prettyName}!`
: `Click to login with ${prettyName}!`}
</Button>
? `Authenticated with ${prettyName}`
: `Login with ${prettyName}`}
</LoadingButton>

{gitAuthPollingState === "abandoned" && (
<Button variant="text" onClick={startPollingGitAuth}>
<ReplayIcon /> Check again
</Button>
)}
{error && <FormHelperText error>{error}</FormHelperText>}
</div>
</Stack>
</Tooltip>
);
};
Expand Down
Loading