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], pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy