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 && (
+
+
+
+ )}
+
+ >
+ );
+};