refactor(site): verify external auth before display ws form (#11777)

This commit is contained in:
Bruno Quaresma
2024-01-24 09:45:22 -03:00
committed by GitHub
parent 5cbb76b47a
commit 6145da8a9e
11 changed files with 533 additions and 377 deletions
@@ -6,14 +6,12 @@ import {
MockUser,
MockWorkspace,
MockWorkspaceQuota,
MockWorkspaceRequest,
MockWorkspaceRichParametersRequest,
MockTemplateVersionParameter1,
MockTemplateVersionParameter2,
MockTemplateVersionParameter3,
MockTemplateVersionExternalAuthGithub,
MockOrganization,
MockTemplateVersionExternalAuthGithubAuthenticated,
} from "testHelpers/entities";
import {
renderWithAuth,
@@ -21,6 +19,8 @@ import {
} from "testHelpers/renderHelpers";
import CreateWorkspacePage from "./CreateWorkspacePage";
import { Language } from "./CreateWorkspacePageView";
import { server } from "testHelpers/server";
import { rest } from "msw";
const nameLabelText = "Workspace Name";
const createWorkspaceText = "Create Workspace";
@@ -157,63 +157,6 @@ describe("CreateWorkspacePage", () => {
expect(validationError).toBeInTheDocument();
});
it("external auth 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, "getTemplateVersionExternalAuth")
.mockResolvedValue([MockTemplateVersionExternalAuthGithub]);
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, "getTemplateVersionExternalAuth")
.mockResolvedValue([MockTemplateVersionExternalAuthGithubAuthenticated]);
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("external auth: errors if unauthenticated", async () => {
jest
.spyOn(API, "getTemplateVersionExternalAuth")
.mockResolvedValueOnce([MockTemplateVersionExternalAuthGithub]);
renderCreateWorkspacePage();
await waitForLoaderToBeRemoved();
await screen.findByText(
"To create a workspace using the selected template, please ensure you are authenticated with all the external providers listed below.",
);
});
it("auto create a workspace if uses mode=auto", async () => {
const param = "first_parameter";
const paramValue = "It works!";
@@ -284,4 +227,46 @@ describe("CreateWorkspacePage", () => {
expect(warningMessage).toHaveTextContent(Language.duplicationWarning);
expect(nameInput).toHaveValue(`${MockWorkspace.name}-copy`);
});
it("displays the form after connecting to all the external services", async () => {
jest.spyOn(window, "open").mockImplementation(() => null);
const user = userEvent.setup();
const notAuthenticatedExternalAuth = {
...MockTemplateVersionExternalAuthGithub,
authenticated: false,
};
server.use(
rest.get(
"/api/v2/templateversions/:versionId/external-auth",
(req, res, ctx) => {
return res(ctx.json([notAuthenticatedExternalAuth]));
},
),
);
renderCreateWorkspacePage();
await screen.findByText("External authentication");
expect(screen.queryByRole("form")).not.toBeInTheDocument();
const connectButton = screen.getByRole("button", {
name: /connect/i,
});
server.use(
rest.get(
"/api/v2/templateversions/:versionId/external-auth",
(req, res, ctx) => {
const authenticatedExternalAuth = {
...MockTemplateVersionExternalAuthGithub,
authenticated: true,
};
return res(ctx.json([authenticatedExternalAuth]));
},
),
);
await user.click(connectButton);
// TODO: Consider improving the timeout by simulating react-query polling.
// Current implementation could not achieve this, further research is
// needed.
await screen.findByRole("form", undefined, { timeout: 10_000 });
});
});
@@ -89,7 +89,7 @@ export const Parameters: Story = {
},
};
export const ExternalAuth: Story = {
export const RequiresExternalAuth: Story = {
args: {
externalAuth: [
{
@@ -25,7 +25,6 @@ import {
ImmutableTemplateParametersSection,
MutableTemplateParametersSection,
} from "components/TemplateParameters/TemplateParameters";
import { ExternalAuthButton } from "./ExternalAuthButton";
import { ErrorAlert } from "components/Alert/ErrorAlert";
import { Stack } from "components/Stack/Stack";
import {
@@ -35,6 +34,7 @@ import {
import { useSearchParams } from "react-router-dom";
import { CreateWSPermissions } from "./permissions";
import { Alert } from "components/Alert/Alert";
import { ExternalAuthBanner } from "./ExternalAuthBanner/ExternalAuthBanner";
import { Margins } from "components/Margins/Margins";
import Button from "@mui/material/Button";
import { Avatar } from "components/Avatar/Avatar";
@@ -155,149 +155,141 @@ export const CreateWorkspacePageView: FC<CreateWorkspacePageViewProps> = ({
</Stack>
</PageHeader>
<HorizontalForm onSubmit={form.handleSubmit} css={{ padding: "16px 0" }}>
{Boolean(error) && <ErrorAlert error={error} />}
{mode === "duplicate" && (
<Alert severity="info" dismissible>
{Language.duplicationWarning}
</Alert>
)}
{/* General info */}
<FormSection
title="General"
description={
permissions.createWorkspaceForUser
? "The name of the workspace and its owner. Only admins can create workspace for other users."
: "The name of your new workspace."
}
{requiresExternalAuth ? (
<ExternalAuthBanner
providers={externalAuth}
pollingState={externalAuthPollingState}
onStartPolling={startPollingExternalAuth}
/>
) : (
<HorizontalForm
name="create-workspace-form"
onSubmit={form.handleSubmit}
css={{ padding: "16px 0" }}
>
<FormFields>
{versionId && versionId !== template.active_version_id && (
<Stack spacing={1} css={styles.hasDescription}>
<TextField
disabled
fullWidth
value={versionId}
label="Version ID"
/>
<span css={styles.description}>
This parameter has been preset, and cannot be modified.
</span>
</Stack>
)}
{Boolean(error) && <ErrorAlert error={error} />}
<TextField
{...getFieldHelpers("name")}
disabled={creatingWorkspace}
// resetMutation facilitates the clearing of validation errors
onChange={onChangeTrimmed(form, resetMutation)}
autoFocus
fullWidth
label="Workspace Name"
/>
{mode === "duplicate" && (
<Alert severity="info" dismissible>
{Language.duplicationWarning}
</Alert>
)}
{permissions.createWorkspaceForUser && (
<UserAutocomplete
value={owner}
onChange={(user) => {
setOwner(user ?? defaultOwner);
}}
label="Owner"
size="medium"
/>
)}
</FormFields>
</FormSection>
{externalAuth && externalAuth.length > 0 && (
{/* General info */}
<FormSection
title="External Authentication"
description="This template requires authentication to external services."
title="General"
description={
permissions.createWorkspaceForUser
? "The name of the workspace and its owner. Only admins can create workspace for other users."
: "The name of your new workspace."
}
>
<FormFields>
{requiresExternalAuth && (
<Alert severity="error">
To create a workspace using the selected template, please
ensure you are authenticated with all the external providers
listed below.
</Alert>
{versionId && versionId !== template.active_version_id && (
<Stack spacing={1} css={styles.hasDescription}>
<TextField
disabled
fullWidth
value={versionId}
label="Version ID"
/>
<span css={styles.description}>
This parameter has been preset, and cannot be modified.
</span>
</Stack>
)}
{externalAuth.map((auth) => (
<ExternalAuthButton
key={auth.id}
auth={auth}
isLoading={externalAuthPollingState === "polling"}
onStartPolling={startPollingExternalAuth}
displayRetry={externalAuthPollingState === "abandoned"}
<TextField
{...getFieldHelpers("name")}
disabled={creatingWorkspace}
// resetMutation facilitates the clearing of validation errors
onChange={onChangeTrimmed(form, resetMutation)}
autoFocus
fullWidth
label="Workspace Name"
/>
{permissions.createWorkspaceForUser && (
<UserAutocomplete
value={owner}
onChange={(user) => {
setOwner(user ?? defaultOwner);
}}
label="Owner"
size="medium"
/>
))}
)}
</FormFields>
</FormSection>
)}
{parameters && (
<>
<MutableTemplateParametersSection
templateParameters={parameters}
getInputProps={(parameter, index) => {
return {
...getFieldHelpers(
"rich_parameter_values[" + index + "].value",
),
onChange: async (value) => {
await form.setFieldValue("rich_parameter_values." + index, {
name: parameter.name,
value: value,
});
},
disabled:
disabledParamsList?.includes(
parameter.name.toLowerCase().replace(/ /g, "_"),
) || creatingWorkspace,
};
}}
/>
<ImmutableTemplateParametersSection
templateParameters={parameters}
classes={{
root: css`
border: 1px solid ${theme.palette.warning.light};
border-radius: 8px;
background-color: ${theme.palette.background.paper};
padding: 80px;
margin-left: -80px;
margin-right: -80px;
`,
}}
getInputProps={(parameter, index) => {
return {
...getFieldHelpers(
"rich_parameter_values[" + index + "].value",
),
onChange: async (value) => {
await form.setFieldValue("rich_parameter_values." + index, {
name: parameter.name,
value: value,
});
},
disabled:
disabledParamsList?.includes(
parameter.name.toLowerCase().replace(/ /g, "_"),
) || creatingWorkspace,
};
}}
/>
</>
)}
{parameters && (
<>
<MutableTemplateParametersSection
templateParameters={parameters}
getInputProps={(parameter, index) => {
return {
...getFieldHelpers(
"rich_parameter_values[" + index + "].value",
),
onChange: async (value) => {
await form.setFieldValue(
"rich_parameter_values." + index,
{
name: parameter.name,
value: value,
},
);
},
disabled:
disabledParamsList?.includes(
parameter.name.toLowerCase().replace(/ /g, "_"),
) || creatingWorkspace,
};
}}
/>
<ImmutableTemplateParametersSection
templateParameters={parameters}
classes={{
root: css`
border: 1px solid ${theme.palette.warning.light};
border-radius: 8px;
background-color: ${theme.palette.background.paper};
padding: 80px;
margin-left: -80px;
margin-right: -80px;
`,
}}
getInputProps={(parameter, index) => {
return {
...getFieldHelpers(
"rich_parameter_values[" + index + "].value",
),
onChange: async (value) => {
await form.setFieldValue(
"rich_parameter_values." + index,
{
name: parameter.name,
value: value,
},
);
},
disabled:
disabledParamsList?.includes(
parameter.name.toLowerCase().replace(/ /g, "_"),
) || creatingWorkspace,
};
}}
/>
</>
)}
<FormFooter
onCancel={onCancel}
isLoading={creatingWorkspace}
submitLabel="Create Workspace"
/>
</HorizontalForm>
<FormFooter
onCancel={onCancel}
isLoading={creatingWorkspace}
submitLabel="Create Workspace"
/>
</HorizontalForm>
)}
</Margins>
);
};
@@ -0,0 +1,34 @@
import { TemplateVersionExternalAuth } from "api/typesGenerated";
import { ExternalAuthBanner } from "./ExternalAuthBanner";
import type { Meta, StoryObj } from "@storybook/react";
const MockExternalAuth: TemplateVersionExternalAuth = {
id: "",
type: "",
display_name: "GitHub",
display_icon: "/icon/github.svg",
authenticate_url: "",
authenticated: false,
};
const meta: Meta<typeof ExternalAuthBanner> = {
title: "pages/CreateWorkspacePage/ExternalAuthBanner",
component: ExternalAuthBanner,
};
export default meta;
type Story = StoryObj<typeof ExternalAuthBanner>;
export const Default: Story = {
args: {
providers: [
MockExternalAuth,
{
...MockExternalAuth,
display_name: "Google",
display_icon: "/icon/google.svg",
authenticated: true,
},
],
},
};
@@ -0,0 +1,91 @@
import { Interpolation, Theme } from "@emotion/react";
import { TemplateVersionExternalAuth } from "api/typesGenerated";
import { ExternalAuthPollingState } from "../CreateWorkspacePage";
import { ExternalAuthItem } from "./ExternalAuthItem";
import { FC } from "react";
type ExternalAuthBannerProps = {
providers: TemplateVersionExternalAuth[];
pollingState: ExternalAuthPollingState;
onStartPolling: () => void;
};
export const ExternalAuthBanner: FC<ExternalAuthBannerProps> = ({
providers,
pollingState,
onStartPolling,
}) => {
return (
<section css={styles.root}>
<div css={styles.content}>
<header css={styles.header}>
<h3 css={styles.title}>External authentication</h3>
<p css={styles.description}>
To create a workspace using the selected template, please ensure you
are connected with all the external services.
</p>
</header>
<ul css={styles.providerList}>
{providers.map((p) => (
<ExternalAuthItem
component="li"
key={p.id}
provider={p}
isPolling={pollingState === "polling"}
onStartPolling={onStartPolling}
/>
))}
</ul>
</div>
</section>
);
};
const styles = {
root: (theme) => ({
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: 48,
minHeight: 460,
border: `1px solid ${theme.palette.divider}`,
borderRadius: 8,
lineHeight: "1.5",
}),
header: {
textAlign: "center",
// Better text distribution
maxWidth: 324,
margin: "auto",
},
content: {
maxWidth: 380,
},
title: {
fontSize: 20,
fontWeight: 400,
margin: 0,
lineHeight: "1.2",
},
description: (theme) => ({
margin: 0,
marginTop: 12,
fontSize: 14,
color: theme.palette.text.secondary,
}),
providerList: {
listStyle: "none",
padding: 0,
margin: 0,
display: "flex",
flexDirection: "column",
gap: 8,
marginTop: 24,
},
} as Record<string, Interpolation<Theme>>;
@@ -0,0 +1,50 @@
import { TemplateVersionExternalAuth } from "api/typesGenerated";
import { ExternalAuthItem } from "./ExternalAuthItem";
import type { Meta, StoryObj } from "@storybook/react";
const MockExternalAuth: TemplateVersionExternalAuth = {
id: "",
type: "",
display_name: "GitHub",
display_icon: "/icon/github.svg",
authenticate_url: "",
authenticated: false,
};
const meta: Meta<typeof ExternalAuthItem> = {
title: "pages/CreateWorkspacePage/ExternalAuthBanner/ExternalAuthItem",
component: ExternalAuthItem,
decorators: [
(Story) => (
<div css={{ maxWidth: 390 }}>
<Story />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof ExternalAuthItem>;
export const Default: Story = {
args: {
provider: MockExternalAuth,
},
};
export const Connected: Story = {
args: {
provider: {
...MockExternalAuth,
authenticated: true,
},
},
};
export const Connecting: Story = {
args: {
provider: MockExternalAuth,
defaultStatus: "connecting",
isPolling: true,
},
};
@@ -0,0 +1,62 @@
import { render, screen } from "@testing-library/react";
import { ExternalAuthItem } from "./ExternalAuthItem";
import { ThemeProvider } from "contexts/ThemeProvider";
import { TemplateVersionExternalAuth } from "api/typesGenerated";
import userEvent from "@testing-library/user-event";
jest.spyOn(window, "open").mockImplementation(() => null);
const MockExternalAuth: TemplateVersionExternalAuth = {
id: "",
type: "",
display_name: "GitHub",
display_icon: "/icon/github.svg",
authenticate_url: "",
authenticated: false,
};
test("changes to idle when polling stops", async () => {
const user = userEvent.setup();
const startPollingFn = jest.fn();
const { rerender } = render(
<ExternalAuthItem
isPolling={false}
provider={MockExternalAuth}
onStartPolling={startPollingFn}
/>,
{ wrapper: ThemeProvider },
);
const connectButton = screen.getByText<HTMLButtonElement>(/connect/i);
expect(isLoading(connectButton)).toBeFalsy();
await user.click(connectButton);
expect(startPollingFn).toHaveBeenCalledTimes(1);
expect(window.open).toHaveBeenCalledTimes(1);
rerender(
<ExternalAuthItem
isPolling
provider={MockExternalAuth}
onStartPolling={startPollingFn}
/>,
);
// Check if the button is loading
screen.getByRole("progressbar");
rerender(
<ExternalAuthItem
isPolling={false}
provider={MockExternalAuth}
onStartPolling={startPollingFn}
/>,
);
expect(isLoading(connectButton)).toBeFalsy();
});
function isLoading(el: HTMLButtonElement) {
const progressBar = el.querySelector('[role="progressbar"]');
return Boolean(progressBar);
}
@@ -0,0 +1,124 @@
import { Interpolation, Theme } from "@emotion/react";
import DoneAllOutlined from "@mui/icons-material/DoneAllOutlined";
import LoadingButton from "@mui/lab/LoadingButton";
import { TemplateVersionExternalAuth } from "api/typesGenerated";
import { ExternalImage } from "components/ExternalImage/ExternalImage";
import { FC, useEffect, useState } from "react";
// eslint-disable-next-line no-restricted-imports -- used to allow extension with "component"
import Box, { BoxProps } from "@mui/material/Box";
type Status = "idle" | "connecting";
type ExternalAuthItemProps = {
provider: TemplateVersionExternalAuth;
isPolling: boolean;
defaultStatus?: Status;
onStartPolling: () => void;
} & BoxProps;
export const ExternalAuthItem: FC<ExternalAuthItemProps> = ({
provider,
isPolling,
defaultStatus = "idle",
onStartPolling,
...boxProps
}) => {
const [status, setStatus] = useState(defaultStatus);
useEffect(() => {
if (!isPolling) {
setStatus("idle");
}
}, [isPolling]);
return (
<Box key={provider.id} css={styles.providerItem} {...boxProps}>
<span css={styles.providerHeader}>
<ExternalImage src={provider.display_icon} css={styles.providerIcon} />
<strong css={styles.providerName}>{provider.display_name}</strong>
</span>
{provider.authenticated ? (
<span css={styles.providerConnectedLabel}>
Connected
<DoneAllOutlined css={styles.providerConnectedLabelIcon} />
</span>
) : (
<LoadingButton
loading={status === "connecting"}
size="small"
css={styles.connectButton}
variant="contained"
color="primary"
onClick={() => {
setStatus("connecting");
window.open(
provider.authenticate_url,
"_blank",
"width=900,height=600",
);
onStartPolling();
}}
>
Connect&hellip;
</LoadingButton>
)}
</Box>
);
};
const styles = {
providerItem: (theme) => ({
display: "flex",
alignItems: "center",
padding: "8px 8px 8px 20px",
border: `1px solid ${theme.palette.divider}`,
borderRadius: 6,
justifyContent: "space-between",
gap: 24,
fontSize: 14,
}),
providerHeader: {
display: "flex",
alignItems: "center",
gap: 12,
flex: 1,
overflow: "hidden",
},
providerName: {
fontWeight: 500,
display: "block",
whiteSpace: "nowrap",
maxWidth: "100%",
textOverflow: "ellipsis",
overflow: "hidden",
},
providerIcon: {
width: 16,
height: 16,
},
connectButton: {
flexShrink: 0,
borderRadius: 4,
},
providerConnectedLabel: (theme) => ({
fontSize: 13,
display: "flex",
alignItems: "center",
color: theme.palette.text.disabled,
gap: 8,
// Have the same height of the button
height: 32,
// Better visual alignment
padding: "0 8px",
}),
providerConnectedLabelIcon: (theme) => ({
color: theme.experimental.roles.success.fill,
fontSize: 16,
}),
} as Record<string, Interpolation<Theme>>;
@@ -1,108 +0,0 @@
import { TemplateVersionExternalAuth } from "api/typesGenerated";
import { ExternalAuthButton } from "./ExternalAuthButton";
import type { Meta, StoryObj } from "@storybook/react";
const MockExternalAuth: TemplateVersionExternalAuth = {
id: "",
type: "",
display_name: "GitHub",
display_icon: "/icon/github.svg",
authenticate_url: "",
authenticated: false,
};
const meta: Meta<typeof ExternalAuthButton> = {
title: "pages/CreateWorkspacePage/ExternalAuth",
component: ExternalAuthButton,
};
export default meta;
type Story = StoryObj<typeof ExternalAuthButton>;
export const Github: Story = {
args: {
auth: MockExternalAuth,
},
};
export const GithubWithRetry: Story = {
args: {
auth: MockExternalAuth,
displayRetry: true,
},
};
export const GithubAuthenticated: Story = {
args: {
auth: {
...MockExternalAuth,
authenticated: true,
},
},
};
export const Gitlab: Story = {
args: {
auth: {
...MockExternalAuth,
display_icon: "/icon/gitlab.svg",
display_name: "GitLab",
authenticated: false,
},
},
};
export const GitlabAuthenticated: Story = {
args: {
auth: {
...MockExternalAuth,
display_icon: "/icon/gitlab.svg",
display_name: "GitLab",
authenticated: true,
},
},
};
export const AzureDevOps: Story = {
args: {
auth: {
...MockExternalAuth,
display_icon: "/icon/azure-devops.svg",
display_name: "Azure DevOps",
authenticated: false,
},
},
};
export const AzureDevOpsAuthenticated: Story = {
args: {
auth: {
...MockExternalAuth,
display_icon: "/icon/azure-devops.svg",
display_name: "Azure DevOps",
authenticated: true,
},
},
};
export const Bitbucket: Story = {
args: {
auth: {
...MockExternalAuth,
display_icon: "/icon/bitbucket.svg",
display_name: "Bitbucket",
authenticated: false,
},
},
};
export const BitbucketAuthenticated: Story = {
args: {
auth: {
...MockExternalAuth,
display_icon: "/icon/bitbucket.svg",
display_name: "Bitbucket",
authenticated: true,
},
},
};
@@ -1,74 +0,0 @@
import ReplayIcon from "@mui/icons-material/Replay";
import Button from "@mui/material/Button";
import Tooltip from "@mui/material/Tooltip";
import { type FC } from "react";
import LoadingButton from "@mui/lab/LoadingButton";
import { visuallyHidden } from "@mui/utils";
import { ExternalImage } from "components/ExternalImage/ExternalImage";
import { TemplateVersionExternalAuth } from "api/typesGenerated";
export interface ExternalAuthButtonProps {
auth: TemplateVersionExternalAuth;
displayRetry: boolean;
isLoading: boolean;
onStartPolling: () => void;
}
export const ExternalAuthButton: FC<ExternalAuthButtonProps> = ({
auth,
displayRetry,
isLoading,
onStartPolling,
}) => {
return (
<>
<div css={{ display: "flex", alignItems: "center", gap: 8 }}>
<LoadingButton
fullWidth
loading={isLoading}
href={auth.authenticate_url}
variant="contained"
size="xlarge"
startIcon={
auth.display_icon && (
<ExternalImage
src={auth.display_icon}
alt={`${auth.display_name} Icon`}
css={{ width: 16, height: 16 }}
/>
)
}
disabled={auth.authenticated}
onClick={() => {
window.open(
auth.authenticate_url,
"_blank",
"width=900,height=600",
);
onStartPolling();
}}
>
{auth.authenticated
? `Authenticated with ${auth.display_name}`
: `Login with ${auth.display_name}`}
</LoadingButton>
{displayRetry && (
<Tooltip title="Retry">
<Button
variant="contained"
size="xlarge"
onClick={onStartPolling}
css={{ minWidth: "auto", aspectRatio: "1" }}
>
<ReplayIcon css={{ width: 20, height: 20 }} />
<span aria-hidden css={{ ...visuallyHidden }}>
Refresh external auth
</span>
</Button>
</Tooltip>
)}
</div>
</>
);
};
+3 -3
View File
@@ -8,9 +8,9 @@ const muiTheme = createTheme({
mode: "dark",
primary: {
main: tw.sky[500],
contrastText: tw.sky[50],
light: tw.sky[300],
dark: tw.sky[400],
contrastText: tw.white,
light: tw.sky[400],
dark: tw.sky[600],
},
secondary: {
main: tw.zinc[500],