mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
refactor(site): verify external auth before display ws form (#11777)
This commit is contained in:
@@ -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…
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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],
|
||||
|
||||
Reference in New Issue
Block a user