From 06f4e16435e01d7e377c942010174545fb0c6f5d Mon Sep 17 00:00:00 2001 From: Asher Date: Wed, 11 Jun 2025 19:50:32 -0800 Subject: [PATCH 1/2] Break out useExternalAuth hook We will use this on the tasks page. --- site/src/hooks/useExternalAuth.ts | 49 +++++++++++++++++++ .../CreateWorkspacePage.tsx | 48 +----------------- .../CreateWorkspacePageView.tsx | 6 +-- .../CreateWorkspacePageViewExperimental.tsx | 6 +-- .../ExternalAuthPage/ExternalAuthPageView.tsx | 2 +- 5 files changed, 55 insertions(+), 56 deletions(-) create mode 100644 site/src/hooks/useExternalAuth.ts diff --git a/site/src/hooks/useExternalAuth.ts b/site/src/hooks/useExternalAuth.ts new file mode 100644 index 0000000000000..5f71944677f8f --- /dev/null +++ b/site/src/hooks/useExternalAuth.ts @@ -0,0 +1,49 @@ +import { templateVersionExternalAuth } from "api/queries/templates"; +import { useCallback, useEffect, useState } from "react"; +import { useQuery } from "react-query"; + +export type ExternalAuthPollingState = "idle" | "polling" | "abandoned"; + +export const useExternalAuth = (versionId: string | undefined) => { + const [externalAuthPollingState, setExternalAuthPollingState] = + useState("idle"); + + const startPollingExternalAuth = useCallback(() => { + setExternalAuthPollingState("polling"); + }, []); + + const { data: externalAuth, isPending: isLoadingExternalAuth } = useQuery({ + ...templateVersionExternalAuth(versionId ?? ""), + enabled: !!versionId, + refetchInterval: externalAuthPollingState === "polling" ? 1000 : false, + }); + + const allSignedIn = externalAuth?.every((it) => it.authenticated); + + useEffect(() => { + if (allSignedIn) { + setExternalAuthPollingState("idle"); + return; + } + + if (externalAuthPollingState !== "polling") { + return; + } + + // Poll for a maximum of one minute + const quitPolling = setTimeout( + () => setExternalAuthPollingState("abandoned"), + 60_000, + ); + return () => { + clearTimeout(quitPolling); + }; + }, [externalAuthPollingState, allSignedIn]); + + return { + startPollingExternalAuth, + externalAuth, + externalAuthPollingState, + isLoadingExternalAuth, + }; +}; diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx index e5a18edbc2224..243bd3cb9be2d 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx @@ -4,7 +4,6 @@ import { checkAuthorization } from "api/queries/authCheck"; import { richParameters, templateByName, - templateVersionExternalAuth, templateVersionPresets, } from "api/queries/templates"; import { autoCreateWorkspace, createWorkspace } from "api/queries/workspaces"; @@ -17,6 +16,7 @@ import type { import { Loader } from "components/Loader/Loader"; import { useAuthenticated } from "hooks"; import { useEffectEvent } from "hooks/hookPolyfills"; +import { useExternalAuth } from "hooks/useExternalAuth"; import { useDashboard } from "modules/dashboard/useDashboard"; import { generateWorkspaceName } from "modules/workspaces/generateWorkspaceName"; import { type FC, useCallback, useEffect, useRef, useState } from "react"; @@ -35,8 +35,6 @@ import { const createWorkspaceModes = ["form", "auto", "duplicate"] as const; export type CreateWorkspaceMode = (typeof createWorkspaceModes)[number]; -export type ExternalAuthPollingState = "idle" | "polling" | "abandoned"; - const CreateWorkspacePage: FC = () => { const { organization: organizationName = "default", template: templateName } = useParams() as { organization?: string; template: string }; @@ -237,50 +235,6 @@ const CreateWorkspacePage: FC = () => { ); }; -const useExternalAuth = (versionId: string | undefined) => { - const [externalAuthPollingState, setExternalAuthPollingState] = - useState("idle"); - - const startPollingExternalAuth = useCallback(() => { - setExternalAuthPollingState("polling"); - }, []); - - const { data: externalAuth, isPending: isLoadingExternalAuth } = useQuery({ - ...templateVersionExternalAuth(versionId ?? ""), - enabled: !!versionId, - refetchInterval: externalAuthPollingState === "polling" ? 1000 : false, - }); - - const allSignedIn = externalAuth?.every((it) => it.authenticated); - - useEffect(() => { - if (allSignedIn) { - setExternalAuthPollingState("idle"); - return; - } - - if (externalAuthPollingState !== "polling") { - return; - } - - // Poll for a maximum of one minute - const quitPolling = setTimeout( - () => setExternalAuthPollingState("abandoned"), - 60_000, - ); - return () => { - clearTimeout(quitPolling); - }; - }, [externalAuthPollingState, allSignedIn]); - - return { - startPollingExternalAuth, - externalAuth, - externalAuthPollingState, - isLoadingExternalAuth, - }; -}; - const getAutofillParameters = ( urlSearchParams: URLSearchParams, userParameters: UserParameter[], diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx index d365a565afcdb..7a880e8df26b6 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx @@ -27,6 +27,7 @@ import { Stack } from "components/Stack/Stack"; import { Switch } from "components/Switch/Switch"; import { UserAutocomplete } from "components/UserAutocomplete/UserAutocomplete"; import { type FormikContextType, useFormik } from "formik"; +import type { ExternalAuthPollingState } from "hooks/useExternalAuth"; import { generateWorkspaceName } from "modules/workspaces/generateWorkspaceName"; import { type FC, useCallback, useEffect, useMemo, useState } from "react"; import { @@ -40,10 +41,7 @@ import { useValidationSchemaForRichParameters, } from "utils/richParameters"; import * as Yup from "yup"; -import type { - CreateWorkspaceMode, - ExternalAuthPollingState, -} from "./CreateWorkspacePage"; +import type { CreateWorkspaceMode } from "./CreateWorkspacePage"; import { ExternalAuthButton } from "./ExternalAuthButton"; import type { CreateWorkspacePermissions } from "./permissions"; diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx index 4fff4db92e21d..d0226332227f9 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx @@ -26,6 +26,7 @@ import { } from "components/Tooltip/Tooltip"; import { UserAutocomplete } from "components/UserAutocomplete/UserAutocomplete"; import { type FormikContextType, useFormik } from "formik"; +import type { ExternalAuthPollingState } from "hooks/useExternalAuth"; import { ArrowLeft, CircleHelp } from "lucide-react"; import { useSyncFormParameters } from "modules/hooks/useSyncFormParameters"; import { Diagnostics } from "modules/workspaces/DynamicParameter/DynamicParameter"; @@ -47,10 +48,7 @@ import { docs } from "utils/docs"; import { nameValidator } from "utils/formUtils"; import type { AutofillBuildParameter } from "utils/richParameters"; import * as Yup from "yup"; -import type { - CreateWorkspaceMode, - ExternalAuthPollingState, -} from "./CreateWorkspacePage"; +import type { CreateWorkspaceMode } from "./CreateWorkspacePage"; import { ExternalAuthButton } from "./ExternalAuthButton"; import type { CreateWorkspacePermissions } from "./permissions"; diff --git a/site/src/pages/UserSettingsPage/ExternalAuthPage/ExternalAuthPageView.tsx b/site/src/pages/UserSettingsPage/ExternalAuthPage/ExternalAuthPageView.tsx index c81dd45c61cd5..b4924a5a09381 100644 --- a/site/src/pages/UserSettingsPage/ExternalAuthPage/ExternalAuthPageView.tsx +++ b/site/src/pages/UserSettingsPage/ExternalAuthPage/ExternalAuthPageView.tsx @@ -27,8 +27,8 @@ import { Loader } from "components/Loader/Loader"; import { Spinner } from "components/Spinner/Spinner"; import { Stack } from "components/Stack/Stack"; import { TableEmpty } from "components/TableEmpty/TableEmpty"; +import type { ExternalAuthPollingState } from "hooks/useExternalAuth"; import { EllipsisVertical } from "lucide-react"; -import type { ExternalAuthPollingState } from "pages/CreateWorkspacePage/CreateWorkspacePage"; import { type FC, useCallback, useEffect, useState } from "react"; import { useQuery } from "react-query"; From 8f021d04311bd6b288a4d5aaeed2a8b7dadceaab Mon Sep 17 00:00:00 2001 From: Asher Date: Wed, 11 Jun 2025 16:02:01 -0800 Subject: [PATCH 2/2] Check external auth before running task It seems we do not validate external auth in the backend currently, so I opted to do this in the frontend to match the create workspace page. --- site/src/hooks/useExternalAuth.ts | 7 +- .../src/pages/TasksPage/TasksPage.stories.tsx | 108 +++++++++++++++++- site/src/pages/TasksPage/TasksPage.tsx | 73 ++++++++++-- 3 files changed, 176 insertions(+), 12 deletions(-) diff --git a/site/src/hooks/useExternalAuth.ts b/site/src/hooks/useExternalAuth.ts index 5f71944677f8f..942ce25fa892e 100644 --- a/site/src/hooks/useExternalAuth.ts +++ b/site/src/hooks/useExternalAuth.ts @@ -12,7 +12,11 @@ export const useExternalAuth = (versionId: string | undefined) => { setExternalAuthPollingState("polling"); }, []); - const { data: externalAuth, isPending: isLoadingExternalAuth } = useQuery({ + const { + data: externalAuth, + isPending: isLoadingExternalAuth, + error, + } = useQuery({ ...templateVersionExternalAuth(versionId ?? ""), enabled: !!versionId, refetchInterval: externalAuthPollingState === "polling" ? 1000 : false, @@ -45,5 +49,6 @@ export const useExternalAuth = (versionId: string | undefined) => { externalAuth, externalAuthPollingState, isLoadingExternalAuth, + externalAuthError: error, }; }; diff --git a/site/src/pages/TasksPage/TasksPage.stories.tsx b/site/src/pages/TasksPage/TasksPage.stories.tsx index 9b6179ab9bae2..287018cf5a2d7 100644 --- a/site/src/pages/TasksPage/TasksPage.stories.tsx +++ b/site/src/pages/TasksPage/TasksPage.stories.tsx @@ -1,9 +1,11 @@ import type { Meta, StoryObj } from "@storybook/react"; -import { expect, spyOn, userEvent, within } from "@storybook/test"; +import { expect, spyOn, userEvent, waitFor, within } from "@storybook/test"; import { API } from "api/api"; import { MockUsers } from "pages/UsersPage/storybookData/users"; import { MockTemplate, + MockTemplateVersionExternalAuthGithub, + MockTemplateVersionExternalAuthGithubAuthenticated, MockUserOwner, MockWorkspace, MockWorkspaceAppStatus, @@ -27,10 +29,20 @@ const meta: Meta = { }, }, beforeEach: () => { + spyOn(API, "getTemplateVersionExternalAuth").mockResolvedValue([]); spyOn(API, "getUsers").mockResolvedValue({ users: MockUsers, count: MockUsers.length, }); + spyOn(data, "fetchAITemplates").mockResolvedValue([ + MockTemplate, + { + ...MockTemplate, + id: "test-template-2", + name: "template 2", + display_name: "Template 2", + }, + ]); }, }; @@ -134,6 +146,7 @@ export const CreateTaskSuccessfully: Story = { const prompt = await canvas.findByLabelText(/prompt/i); await userEvent.type(prompt, newTaskData.prompt); const submitButton = canvas.getByRole("button", { name: /run task/i }); + await waitFor(() => expect(submitButton).toBeEnabled()); await userEvent.click(submitButton); }); @@ -164,6 +177,7 @@ export const CreateTaskError: Story = { const prompt = await canvas.findByLabelText(/prompt/i); await userEvent.type(prompt, "Create a new task"); const submitButton = canvas.getByRole("button", { name: /run task/i }); + await waitFor(() => expect(submitButton).toBeEnabled()); await userEvent.click(submitButton); }); @@ -173,6 +187,98 @@ export const CreateTaskError: Story = { }, }; +export const WithExternalAuth: Story = { + decorators: [withProxyProvider()], + beforeEach: () => { + spyOn(data, "fetchTasks") + .mockResolvedValueOnce(MockTasks) + .mockResolvedValue([newTaskData, ...MockTasks]); + spyOn(data, "createTask").mockResolvedValue(newTaskData); + spyOn(API, "getTemplateVersionExternalAuth").mockResolvedValue([ + MockTemplateVersionExternalAuthGithubAuthenticated, + ]); + }, + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + + await step("Run task", async () => { + const prompt = await canvas.findByLabelText(/prompt/i); + await userEvent.type(prompt, newTaskData.prompt); + const submitButton = canvas.getByRole("button", { name: /run task/i }); + await waitFor(() => expect(submitButton).toBeEnabled()); + await userEvent.click(submitButton); + }); + + await step("Verify task in the table", async () => { + await canvas.findByRole("row", { + name: new RegExp(newTaskData.prompt, "i"), + }); + }); + + await step("Does not render external auth", async () => { + expect( + canvas.queryByText(/external authentication/), + ).not.toBeInTheDocument(); + }); + }, +}; + +export const MissingExternalAuth: Story = { + decorators: [withProxyProvider()], + beforeEach: () => { + spyOn(data, "fetchTasks") + .mockResolvedValueOnce(MockTasks) + .mockResolvedValue([newTaskData, ...MockTasks]); + spyOn(data, "createTask").mockResolvedValue(newTaskData); + spyOn(API, "getTemplateVersionExternalAuth").mockResolvedValue([ + MockTemplateVersionExternalAuthGithub, + ]); + }, + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + + await step("Submit is disabled", async () => { + const prompt = await canvas.findByLabelText(/prompt/i); + await userEvent.type(prompt, newTaskData.prompt); + const submitButton = canvas.getByRole("button", { name: /run task/i }); + expect(submitButton).toBeDisabled(); + }); + + await step("Renders external authentication", async () => { + await canvas.findByRole("button", { name: /login with github/i }); + }); + }, +}; + +export const ExternalAuthError: Story = { + decorators: [withProxyProvider()], + beforeEach: () => { + spyOn(data, "fetchTasks") + .mockResolvedValueOnce(MockTasks) + .mockResolvedValue([newTaskData, ...MockTasks]); + spyOn(data, "createTask").mockResolvedValue(newTaskData); + spyOn(API, "getTemplateVersionExternalAuth").mockRejectedValue( + mockApiError({ + message: "Failed to load external auth", + }), + ); + }, + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + + await step("Submit is disabled", async () => { + const prompt = await canvas.findByLabelText(/prompt/i); + await userEvent.type(prompt, newTaskData.prompt); + const submitButton = canvas.getByRole("button", { name: /run task/i }); + expect(submitButton).toBeDisabled(); + }); + + await step("Renders error", async () => { + await canvas.findByText(/failed to load external auth/i); + }); + }, +}; + export const NonAdmin: Story = { decorators: [withProxyProvider()], parameters: { diff --git a/site/src/pages/TasksPage/TasksPage.tsx b/site/src/pages/TasksPage/TasksPage.tsx index adb978cb05cac..02f7f5651092e 100644 --- a/site/src/pages/TasksPage/TasksPage.tsx +++ b/site/src/pages/TasksPage/TasksPage.tsx @@ -2,9 +2,11 @@ import { API } from "api/api"; import { getErrorDetail, getErrorMessage } from "api/errors"; import { disabledRefetchOptions } from "api/queries/util"; import type { Template } from "api/typesGenerated"; +import { ErrorAlert } from "components/Alert/ErrorAlert"; import { Avatar } from "components/Avatar/Avatar"; import { AvatarData } from "components/Avatar/AvatarData"; import { Button } from "components/Button/Button"; +import { Form, FormFields, FormSection } from "components/Form/Form"; import { displayError } from "components/GlobalSnackbar/utils"; import { Margins } from "components/Margins/Margins"; import { @@ -28,7 +30,9 @@ import { TableHeader, TableRow, } from "components/Table/Table"; + import { useAuthenticated } from "hooks"; +import { useExternalAuth } from "hooks/useExternalAuth"; import { ExternalLinkIcon, RotateCcwIcon, SendIcon } from "lucide-react"; import { AI_PROMPT_PARAMETER_NAME, type Task } from "modules/tasks/tasks"; import { WorkspaceAppStatus } from "modules/workspaces/WorkspaceAppStatus/WorkspaceAppStatus"; @@ -40,6 +44,7 @@ import { Link as RouterLink } from "react-router-dom"; import TextareaAutosize from "react-textarea-autosize"; import { pageTitle } from "utils/page"; import { relativeTime } from "utils/time"; +import { ExternalAuthButton } from "../CreateWorkspacePage/ExternalAuthButton"; import { type UserOption, UsersCombobox } from "./UsersCombobox"; type TasksFilter = { @@ -161,6 +166,21 @@ const TaskForm: FC = ({ templates }) => { const { user } = useAuthenticated(); const queryClient = useQueryClient(); + const [templateId, setTemplateId] = useState(templates[0].id); + const { + externalAuth, + externalAuthPollingState, + startPollingExternalAuth, + isLoadingExternalAuth, + externalAuthError, + } = useExternalAuth( + templates.find((t) => t.id === templateId)?.active_version_id, + ); + + const hasAllRequiredExternalAuth = externalAuth?.every( + (auth) => auth.optional || auth.authenticated, + ); + const createTaskMutation = useMutation({ mutationFn: async ({ prompt, templateId }: CreateTaskMutationFnProps) => data.createTask(prompt, user.id, templateId), @@ -197,12 +217,13 @@ const TaskForm: FC = ({ templates }) => { }; return ( -
-
+ + {Boolean(externalAuthError) && } + +
@@ -215,7 +236,12 @@ const TaskForm: FC = ({ templates }) => { text-sm shadow-sm text-content-primary placeholder:text-content-secondary md:text-sm`} />
- setTemplateId(value)} + defaultValue={templates[0].id} + required + > @@ -232,15 +258,42 @@ const TaskForm: FC = ({ templates }) => { -
- + + {!hasAllRequiredExternalAuth && + externalAuth && + externalAuth.length > 0 && ( + + + {externalAuth.map((auth) => ( + + ))} + + + )} + ); };