diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.test.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.test.tsx index 78a1653e0eed9..a616c106bf41c 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.test.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.test.tsx @@ -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 }); + }); }); diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.stories.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.stories.tsx index 002b6c678a857..38e118431a559 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.stories.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.stories.tsx @@ -89,7 +89,7 @@ export const Parameters: Story = { }, }; -export const ExternalAuth: Story = { +export const RequiresExternalAuth: Story = { args: { externalAuth: [ { diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx index 783bce24eae96..b6b6968e945d6 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx @@ -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 = ({ - - {Boolean(error) && } - - {mode === "duplicate" && ( - - {Language.duplicationWarning} - - )} - - {/* General info */} - + ) : ( + - - {versionId && versionId !== template.active_version_id && ( - - - - This parameter has been preset, and cannot be modified. - - - )} - - + {Boolean(error) && } - {permissions.createWorkspaceForUser && ( - { - setOwner(user ?? defaultOwner); - }} - label="Owner" - size="medium" - /> - )} - - + {mode === "duplicate" && ( + + {Language.duplicationWarning} + + )} - {externalAuth && externalAuth.length > 0 && ( + {/* General info */} - {requiresExternalAuth && ( - - To create a workspace using the selected template, please - ensure you are authenticated with all the external providers - listed below. - + {versionId && versionId !== template.active_version_id && ( + + + + This parameter has been preset, and cannot be modified. + + )} - {externalAuth.map((auth) => ( - + + {permissions.createWorkspaceForUser && ( + { + setOwner(user ?? defaultOwner); + }} + label="Owner" + size="medium" /> - ))} + )} - )} - {parameters && ( - <> - { - 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, - }; - }} - /> - { - 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 && ( + <> + { + 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, + }; + }} + /> + { + 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, + }; + }} + /> + + )} - - + + + )} ); }; diff --git a/site/src/pages/CreateWorkspacePage/ExternalAuthBanner/ExternalAuthBanner.stories.tsx b/site/src/pages/CreateWorkspacePage/ExternalAuthBanner/ExternalAuthBanner.stories.tsx new file mode 100644 index 0000000000000..2d9c4e9c45359 --- /dev/null +++ b/site/src/pages/CreateWorkspacePage/ExternalAuthBanner/ExternalAuthBanner.stories.tsx @@ -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 = { + title: "pages/CreateWorkspacePage/ExternalAuthBanner", + component: ExternalAuthBanner, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + providers: [ + MockExternalAuth, + { + ...MockExternalAuth, + display_name: "Google", + display_icon: "/icon/google.svg", + authenticated: true, + }, + ], + }, +}; diff --git a/site/src/pages/CreateWorkspacePage/ExternalAuthBanner/ExternalAuthBanner.tsx b/site/src/pages/CreateWorkspacePage/ExternalAuthBanner/ExternalAuthBanner.tsx new file mode 100644 index 0000000000000..ee8e4b47f546d --- /dev/null +++ b/site/src/pages/CreateWorkspacePage/ExternalAuthBanner/ExternalAuthBanner.tsx @@ -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 = ({ + providers, + pollingState, + onStartPolling, +}) => { + return ( +
+
+
+

External authentication

+

+ To create a workspace using the selected template, please ensure you + are connected with all the external services. +

+
+ +
    + {providers.map((p) => ( + + ))} +
+
+
+ ); +}; + +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>; diff --git a/site/src/pages/CreateWorkspacePage/ExternalAuthBanner/ExternalAuthItem.stories.tsx b/site/src/pages/CreateWorkspacePage/ExternalAuthBanner/ExternalAuthItem.stories.tsx new file mode 100644 index 0000000000000..a60b3e317c19e --- /dev/null +++ b/site/src/pages/CreateWorkspacePage/ExternalAuthBanner/ExternalAuthItem.stories.tsx @@ -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 = { + title: "pages/CreateWorkspacePage/ExternalAuthBanner/ExternalAuthItem", + component: ExternalAuthItem, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export default meta; +type Story = StoryObj; + +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, + }, +}; diff --git a/site/src/pages/CreateWorkspacePage/ExternalAuthBanner/ExternalAuthItem.test.tsx b/site/src/pages/CreateWorkspacePage/ExternalAuthBanner/ExternalAuthItem.test.tsx new file mode 100644 index 0000000000000..f0d0871b9f7e3 --- /dev/null +++ b/site/src/pages/CreateWorkspacePage/ExternalAuthBanner/ExternalAuthItem.test.tsx @@ -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( + , + { wrapper: ThemeProvider }, + ); + + const connectButton = screen.getByText(/connect/i); + expect(isLoading(connectButton)).toBeFalsy(); + + await user.click(connectButton); + expect(startPollingFn).toHaveBeenCalledTimes(1); + expect(window.open).toHaveBeenCalledTimes(1); + + rerender( + , + ); + + // Check if the button is loading + screen.getByRole("progressbar"); + + rerender( + , + ); + + expect(isLoading(connectButton)).toBeFalsy(); +}); + +function isLoading(el: HTMLButtonElement) { + const progressBar = el.querySelector('[role="progressbar"]'); + return Boolean(progressBar); +} diff --git a/site/src/pages/CreateWorkspacePage/ExternalAuthBanner/ExternalAuthItem.tsx b/site/src/pages/CreateWorkspacePage/ExternalAuthBanner/ExternalAuthItem.tsx new file mode 100644 index 0000000000000..d50174e58eea8 --- /dev/null +++ b/site/src/pages/CreateWorkspacePage/ExternalAuthBanner/ExternalAuthItem.tsx @@ -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 = ({ + provider, + isPolling, + defaultStatus = "idle", + onStartPolling, + ...boxProps +}) => { + const [status, setStatus] = useState(defaultStatus); + + useEffect(() => { + if (!isPolling) { + setStatus("idle"); + } + }, [isPolling]); + + return ( + + + + {provider.display_name} + + {provider.authenticated ? ( + + Connected + + + ) : ( + { + setStatus("connecting"); + window.open( + provider.authenticate_url, + "_blank", + "width=900,height=600", + ); + onStartPolling(); + }} + > + Connect… + + )} + + ); +}; + +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>; diff --git a/site/src/pages/CreateWorkspacePage/ExternalAuthButton.stories.tsx b/site/src/pages/CreateWorkspacePage/ExternalAuthButton.stories.tsx deleted file mode 100644 index 97c9d743552ad..0000000000000 --- a/site/src/pages/CreateWorkspacePage/ExternalAuthButton.stories.tsx +++ /dev/null @@ -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 = { - title: "pages/CreateWorkspacePage/ExternalAuth", - component: ExternalAuthButton, -}; - -export default meta; -type Story = StoryObj; - -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, - }, - }, -}; diff --git a/site/src/pages/CreateWorkspacePage/ExternalAuthButton.tsx b/site/src/pages/CreateWorkspacePage/ExternalAuthButton.tsx deleted file mode 100644 index 3412a9aac0b3d..0000000000000 --- a/site/src/pages/CreateWorkspacePage/ExternalAuthButton.tsx +++ /dev/null @@ -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 = ({ - auth, - displayRetry, - isLoading, - onStartPolling, -}) => { - return ( - <> -
- - ) - } - 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}`} - - - {displayRetry && ( - - - - )} -
- - ); -}; diff --git a/site/src/theme/dark/mui.ts b/site/src/theme/dark/mui.ts index aadad14a87e81..52927eda2e0c9 100644 --- a/site/src/theme/dark/mui.ts +++ b/site/src/theme/dark/mui.ts @@ -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],