diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.test.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.test.tsx index f4ffaecf5285c..78a1653e0eed9 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.test.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.test.tsx @@ -201,7 +201,7 @@ describe("CreateWorkspacePage", () => { ); }); - it("external auth: errors if unauthenticated and submits", async () => { + it("external auth: errors if unauthenticated", async () => { jest .spyOn(API, "getTemplateVersionExternalAuth") .mockResolvedValueOnce([MockTemplateVersionExternalAuthGithub]); @@ -209,17 +209,9 @@ describe("CreateWorkspacePage", () => { 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 submitButton = screen.getByText(createWorkspaceText); - await userEvent.click(submitButton); - - await screen.findByText("You must authenticate to create a workspace!"); + 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 () => { diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx index 309aa6c4196e0..3f58a7177c498 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx @@ -68,8 +68,12 @@ const CreateWorkspacePage: FC = () => { ? richParametersQuery.data.filter(paramsUsedToCreateWorkspace) : undefined; - const { externalAuth, externalAuthPollingState, startPollingExternalAuth } = - useExternalAuth(realizedVersionId); + const { + externalAuth, + externalAuthPollingState, + startPollingExternalAuth, + isLoadingExternalAuth, + } = useExternalAuth(realizedVersionId); const isLoadingFormData = templateQuery.isLoading || @@ -118,7 +122,9 @@ const CreateWorkspacePage: FC = () => { {pageTitle(title)} {loadFormDataError && } - {isLoadingFormData || autoCreateWorkspaceMutation.isLoading ? ( + {isLoadingFormData || + isLoadingExternalAuth || + autoCreateWorkspaceMutation.isLoading ? ( ) : ( { setExternalAuthPollingState("polling"); }, []); - const { data: externalAuth } = useQuery( + const { data: externalAuth, isLoading: isLoadingExternalAuth } = useQuery( versionId ? { ...templateVersionExternalAuth(versionId), @@ -205,6 +211,7 @@ const useExternalAuth = (versionId: string | undefined) => { startPollingExternalAuth, externalAuth, externalAuthPollingState, + isLoadingExternalAuth, }; }; diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx index 2c18de035b3dd..7c30434f6b73f 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx @@ -27,12 +27,12 @@ import { ImmutableTemplateParametersSection, MutableTemplateParametersSection, } from "components/TemplateParameters/TemplateParameters"; -import { ExternalAuth } from "./ExternalAuth"; +import { ExternalAuthButton } from "./ExternalAuthButton"; import { ErrorAlert } from "components/Alert/ErrorAlert"; import { Stack } from "components/Stack/Stack"; import { CreateWorkspaceMode, - type ExternalAuthPollingState, + ExternalAuthPollingState, } from "./CreateWorkspacePage"; import { useSearchParams } from "react-router-dom"; import { CreateWSPermissions } from "./permissions"; @@ -85,10 +85,9 @@ export const CreateWorkspacePageView: FC = ({ }) => { const theme = useTheme(); const [owner, setOwner] = useState(defaultOwner); - const { verifyExternalAuth, externalAuthErrors } = - useExternalAuthVerification(externalAuth); const [searchParams] = useSearchParams(); const disabledParamsList = searchParams?.get("disable_params")?.split(","); + const requiresExternalAuth = externalAuth.some((auth) => !auth.authenticated); const form: FormikContextType = useFormik({ @@ -106,7 +105,7 @@ export const CreateWorkspacePageView: FC = ({ }), enableReinitialize: true, onSubmit: (request) => { - if (!verifyExternalAuth()) { + if (requiresExternalAuth) { return; } @@ -192,16 +191,20 @@ export const CreateWorkspacePageView: FC = ({ description="This template requires authentication to external services." > + {requiresExternalAuth && ( + + To create a workspace using the selected template, please + ensure you are authenticated with all the external providers + listed below. + + )} {externalAuth.map((auth) => ( - ))} @@ -273,43 +276,6 @@ export const CreateWorkspacePageView: FC = ({ ); }; -type ExternalAuthErrors = Record; - -const useExternalAuthVerification = ( - externalAuth: TypesGen.TemplateVersionExternalAuth[], -) => { - const [externalAuthErrors, setExternalAuthErrors] = - useState({}); - - // Clear errors when externalAuth is refreshed - useEffect(() => { - setExternalAuthErrors({}); - }, [externalAuth]); - - const verifyExternalAuth = () => { - const errors: ExternalAuthErrors = {}; - - for (let i = 0; i < externalAuth.length; i++) { - const auth = externalAuth.at(i); - if (!auth) { - continue; - } - if (!auth.authenticated) { - errors[auth.id] = "You must authenticate to create a workspace!"; - } - } - - setExternalAuthErrors(errors); - const isValid = Object.keys(errors).length === 0; - return isValid; - }; - - return { - externalAuthErrors, - verifyExternalAuth, - }; -}; - const styles = { hasDescription: { paddingBottom: 16, diff --git a/site/src/pages/CreateWorkspacePage/ExternalAuth.stories.tsx b/site/src/pages/CreateWorkspacePage/ExternalAuth.stories.tsx deleted file mode 100644 index 72e313968c688..0000000000000 --- a/site/src/pages/CreateWorkspacePage/ExternalAuth.stories.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import { ExternalAuth } from "./ExternalAuth"; -import type { Meta, StoryObj } from "@storybook/react"; - -const meta: Meta = { - title: "pages/CreateWorkspacePage/ExternalAuth", - component: ExternalAuth, -}; - -export default meta; -type Story = StoryObj; - -export const Github: Story = { - args: { - displayIcon: "/icon/github.svg", - displayName: "GitHub", - authenticated: false, - }, -}; - -export const GithubTimeout: Story = { - args: { - displayIcon: "/icon/github.svg", - displayName: "GitHub", - authenticated: false, - externalAuthPollingState: "abandoned", - }, -}; - -export const GithubFailed: Story = { - args: { - displayIcon: "/icon/github.svg", - displayName: "GitHub", - authenticated: false, - error: "Github doesn't like you", - }, -}; - -export const GithubAuthenticated: Story = { - args: { - displayIcon: "/icon/github.svg", - displayName: "GitHub", - authenticated: true, - }, -}; - -export const Gitlab: Story = { - args: { - displayIcon: "/icon/gitlab.svg", - displayName: "GitLab", - authenticated: false, - }, -}; - -export const GitlabAuthenticated: Story = { - args: { - displayIcon: "/icon/gitlab.svg", - displayName: "GitLab", - authenticated: true, - }, -}; - -export const AzureDevOps: Story = { - args: { - displayIcon: "/icon/azure-devops.svg", - displayName: "Azure DevOps", - authenticated: false, - }, -}; - -export const AzureDevOpsAuthenticated: Story = { - args: { - displayIcon: "/icon/azure-devops.svg", - displayName: "Azure DevOps", - authenticated: true, - }, -}; - -export const Bitbucket: Story = { - args: { - displayIcon: "/icon/bitbucket.svg", - displayName: "Bitbucket", - authenticated: false, - }, -}; - -export const BitbucketAuthenticated: Story = { - args: { - displayIcon: "/icon/bitbucket.svg", - displayName: "Bitbucket", - authenticated: true, - }, -}; diff --git a/site/src/pages/CreateWorkspacePage/ExternalAuth.tsx b/site/src/pages/CreateWorkspacePage/ExternalAuth.tsx deleted file mode 100644 index df7bf721c08cd..0000000000000 --- a/site/src/pages/CreateWorkspacePage/ExternalAuth.tsx +++ /dev/null @@ -1,96 +0,0 @@ -import ReplayIcon from "@mui/icons-material/Replay"; -import Button from "@mui/material/Button"; -import FormHelperText from "@mui/material/FormHelperText"; -import Tooltip from "@mui/material/Tooltip"; -import { type FC } from "react"; -import { Stack } from "components/Stack/Stack"; -import { type ExternalAuthPollingState } from "./CreateWorkspacePage"; -import LoadingButton from "@mui/lab/LoadingButton"; - -export interface ExternalAuthProps { - displayName: string; - displayIcon: string; - authenticated: boolean; - authenticateURL: string; - externalAuthPollingState: ExternalAuthPollingState; - startPollingExternalAuth: () => void; - error?: string; - message?: string; - fullWidth?: boolean; -} - -export const ExternalAuth: FC = ({ - displayName, - displayIcon, - authenticated, - authenticateURL, - externalAuthPollingState, - startPollingExternalAuth, - error, - message, - fullWidth = true, -}) => { - const messageContent = - message ?? - (authenticated - ? `Authenticated with ${displayName}` - : `Login with ${displayName}`); - - return ( - <> - - - - ) - } - disabled={authenticated} - css={{ height: 42 }} - fullWidth={fullWidth} - onClick={(event) => { - event.preventDefault(); - // If the user is already authenticated, we don't want to redirect them - if (authenticated || authenticateURL === "") { - return; - } - window.open(authenticateURL, "_blank", "width=900,height=600"); - startPollingExternalAuth(); - }} - > - {messageContent} - - - {externalAuthPollingState === "abandoned" && ( - - )} - - - {error && ( - ({ color: theme.experimental.roles.error.text })} - > - {error} - - )} - - ); -}; diff --git a/site/src/pages/CreateWorkspacePage/ExternalAuthButton.stories.tsx b/site/src/pages/CreateWorkspacePage/ExternalAuthButton.stories.tsx new file mode 100644 index 0000000000000..97c9d743552ad --- /dev/null +++ b/site/src/pages/CreateWorkspacePage/ExternalAuthButton.stories.tsx @@ -0,0 +1,108 @@ +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 new file mode 100644 index 0000000000000..3412a9aac0b3d --- /dev/null +++ b/site/src/pages/CreateWorkspacePage/ExternalAuthButton.tsx @@ -0,0 +1,74 @@ +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 && ( + + + + )} +
+ + ); +};