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