Skip to content

Commit 750f4b6

Browse files
committed
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.
1 parent 3b07068 commit 750f4b6

File tree

3 files changed

+176
-12
lines changed

3 files changed

+176
-12
lines changed

site/src/hooks/useExternalAuth.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,11 @@ export const useExternalAuth = (versionId: string | undefined) => {
1212
setExternalAuthPollingState("polling");
1313
}, []);
1414

15-
const { data: externalAuth, isPending: isLoadingExternalAuth } = useQuery({
15+
const {
16+
data: externalAuth,
17+
isPending: isLoadingExternalAuth,
18+
error,
19+
} = useQuery({
1620
...templateVersionExternalAuth(versionId ?? ""),
1721
enabled: !!versionId,
1822
refetchInterval: externalAuthPollingState === "polling" ? 1000 : false,
@@ -45,5 +49,6 @@ export const useExternalAuth = (versionId: string | undefined) => {
4549
externalAuth,
4650
externalAuthPollingState,
4751
isLoadingExternalAuth,
52+
externalAuthError: error,
4853
};
4954
};

site/src/pages/TasksPage/TasksPage.stories.tsx

Lines changed: 107 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import type { Meta, StoryObj } from "@storybook/react";
2-
import { expect, spyOn, userEvent, within } from "@storybook/test";
2+
import { expect, spyOn, userEvent, waitFor, within } from "@storybook/test";
33
import { API } from "api/api";
44
import { MockUsers } from "pages/UsersPage/storybookData/users";
55
import {
66
MockTemplate,
7+
MockTemplateVersionExternalAuthGithub,
8+
MockTemplateVersionExternalAuthGithubAuthenticated,
79
MockUserOwner,
810
MockWorkspace,
911
MockWorkspaceAppStatus,
@@ -27,10 +29,20 @@ const meta: Meta<typeof TasksPage> = {
2729
},
2830
},
2931
beforeEach: () => {
32+
spyOn(API, "getTemplateVersionExternalAuth").mockResolvedValue([]);
3033
spyOn(API, "getUsers").mockResolvedValue({
3134
users: MockUsers,
3235
count: MockUsers.length,
3336
});
37+
spyOn(data, "fetchAITemplates").mockResolvedValue([
38+
MockTemplate,
39+
{
40+
...MockTemplate,
41+
id: "test-template-2",
42+
name: "template 2",
43+
display_name: "Template 2",
44+
},
45+
]);
3446
},
3547
};
3648

@@ -134,6 +146,7 @@ export const CreateTaskSuccessfully: Story = {
134146
const prompt = await canvas.findByLabelText(/prompt/i);
135147
await userEvent.type(prompt, newTaskData.prompt);
136148
const submitButton = canvas.getByRole("button", { name: /run task/i });
149+
await waitFor(() => expect(submitButton).toBeEnabled());
137150
await userEvent.click(submitButton);
138151
});
139152

@@ -164,6 +177,7 @@ export const CreateTaskError: Story = {
164177
const prompt = await canvas.findByLabelText(/prompt/i);
165178
await userEvent.type(prompt, "Create a new task");
166179
const submitButton = canvas.getByRole("button", { name: /run task/i });
180+
await waitFor(() => expect(submitButton).toBeEnabled());
167181
await userEvent.click(submitButton);
168182
});
169183

@@ -173,6 +187,98 @@ export const CreateTaskError: Story = {
173187
},
174188
};
175189

190+
export const WithExternalAuth: Story = {
191+
decorators: [withProxyProvider()],
192+
beforeEach: () => {
193+
spyOn(data, "fetchTasks")
194+
.mockResolvedValueOnce(MockTasks)
195+
.mockResolvedValue([newTaskData, ...MockTasks]);
196+
spyOn(data, "createTask").mockResolvedValue(newTaskData);
197+
spyOn(API, "getTemplateVersionExternalAuth").mockResolvedValue([
198+
MockTemplateVersionExternalAuthGithubAuthenticated,
199+
]);
200+
},
201+
play: async ({ canvasElement, step }) => {
202+
const canvas = within(canvasElement);
203+
204+
await step("Run task", async () => {
205+
const prompt = await canvas.findByLabelText(/prompt/i);
206+
await userEvent.type(prompt, newTaskData.prompt);
207+
const submitButton = canvas.getByRole("button", { name: /run task/i });
208+
await waitFor(() => expect(submitButton).toBeEnabled());
209+
await userEvent.click(submitButton);
210+
});
211+
212+
await step("Verify task in the table", async () => {
213+
await canvas.findByRole("row", {
214+
name: new RegExp(newTaskData.prompt, "i"),
215+
});
216+
});
217+
218+
await step("Does not render external auth", async () => {
219+
expect(
220+
canvas.queryByText(/external authentication/),
221+
).not.toBeInTheDocument();
222+
});
223+
},
224+
};
225+
226+
export const MissingExternalAuth: Story = {
227+
decorators: [withProxyProvider()],
228+
beforeEach: () => {
229+
spyOn(data, "fetchTasks")
230+
.mockResolvedValueOnce(MockTasks)
231+
.mockResolvedValue([newTaskData, ...MockTasks]);
232+
spyOn(data, "createTask").mockResolvedValue(newTaskData);
233+
spyOn(API, "getTemplateVersionExternalAuth").mockResolvedValue([
234+
MockTemplateVersionExternalAuthGithub,
235+
]);
236+
},
237+
play: async ({ canvasElement, step }) => {
238+
const canvas = within(canvasElement);
239+
240+
await step("Submit is disabled", async () => {
241+
const prompt = await canvas.findByLabelText(/prompt/i);
242+
await userEvent.type(prompt, newTaskData.prompt);
243+
const submitButton = canvas.getByRole("button", { name: /run task/i });
244+
expect(submitButton).toBeDisabled();
245+
});
246+
247+
await step("Renders external authentication", async () => {
248+
await canvas.findByRole("button", { name: /login with github/i });
249+
});
250+
},
251+
};
252+
253+
export const ExternalAuthError: Story = {
254+
decorators: [withProxyProvider()],
255+
beforeEach: () => {
256+
spyOn(data, "fetchTasks")
257+
.mockResolvedValueOnce(MockTasks)
258+
.mockResolvedValue([newTaskData, ...MockTasks]);
259+
spyOn(data, "createTask").mockResolvedValue(newTaskData);
260+
spyOn(API, "getTemplateVersionExternalAuth").mockRejectedValue(
261+
mockApiError({
262+
message: "Failed to load external auth",
263+
}),
264+
);
265+
},
266+
play: async ({ canvasElement, step }) => {
267+
const canvas = within(canvasElement);
268+
269+
await step("Submit is disabled", async () => {
270+
const prompt = await canvas.findByLabelText(/prompt/i);
271+
await userEvent.type(prompt, newTaskData.prompt);
272+
const submitButton = canvas.getByRole("button", { name: /run task/i });
273+
expect(submitButton).toBeDisabled();
274+
});
275+
276+
await step("Renders error", async () => {
277+
await canvas.findByText(/failed to load external auth/i);
278+
});
279+
},
280+
};
281+
176282
export const NonAdmin: Story = {
177283
decorators: [withProxyProvider()],
178284
parameters: {

site/src/pages/TasksPage/TasksPage.tsx

Lines changed: 63 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@ import { API } from "api/api";
22
import { getErrorDetail, getErrorMessage } from "api/errors";
33
import { disabledRefetchOptions } from "api/queries/util";
44
import type { Template } from "api/typesGenerated";
5+
import { ErrorAlert } from "components/Alert/ErrorAlert";
56
import { Avatar } from "components/Avatar/Avatar";
67
import { AvatarData } from "components/Avatar/AvatarData";
78
import { Button } from "components/Button/Button";
9+
import { Form, FormFields, FormSection } from "components/Form/Form";
810
import { displayError } from "components/GlobalSnackbar/utils";
911
import { Margins } from "components/Margins/Margins";
1012
import {
@@ -28,7 +30,9 @@ import {
2830
TableHeader,
2931
TableRow,
3032
} from "components/Table/Table";
33+
3134
import { useAuthenticated } from "hooks";
35+
import { useExternalAuth } from "hooks/useExternalAuth";
3236
import { ExternalLinkIcon, RotateCcwIcon, SendIcon } from "lucide-react";
3337
import { AI_PROMPT_PARAMETER_NAME, type Task } from "modules/tasks/tasks";
3438
import { WorkspaceAppStatus } from "modules/workspaces/WorkspaceAppStatus/WorkspaceAppStatus";
@@ -40,6 +44,7 @@ import { Link as RouterLink } from "react-router-dom";
4044
import TextareaAutosize from "react-textarea-autosize";
4145
import { pageTitle } from "utils/page";
4246
import { relativeTime } from "utils/time";
47+
import { ExternalAuthButton } from "../CreateWorkspacePage/ExternalAuthButton";
4348
import { type UserOption, UsersCombobox } from "./UsersCombobox";
4449

4550
type TasksFilter = {
@@ -161,6 +166,21 @@ const TaskForm: FC<TaskFormProps> = ({ templates }) => {
161166
const { user } = useAuthenticated();
162167
const queryClient = useQueryClient();
163168

169+
const [templateId, setTemplateId] = useState<string>(templates[0].id);
170+
const {
171+
externalAuth,
172+
externalAuthPollingState,
173+
startPollingExternalAuth,
174+
isLoadingExternalAuth,
175+
externalAuthError,
176+
} = useExternalAuth(
177+
templates.find((t) => t.id === templateId)?.active_version_id,
178+
);
179+
180+
const hasAllRequiredExternalAuth = externalAuth?.every(
181+
(auth) => auth.optional || auth.authenticated,
182+
);
183+
164184
const createTaskMutation = useMutation({
165185
mutationFn: async ({ prompt, templateId }: CreateTaskMutationFnProps) =>
166186
data.createTask(prompt, user.id, templateId),
@@ -197,12 +217,13 @@ const TaskForm: FC<TaskFormProps> = ({ templates }) => {
197217
};
198218

199219
return (
200-
<form
201-
className="border border-border border-solid rounded-lg p-4"
202-
onSubmit={onSubmit}
203-
aria-label="Create AI task"
204-
>
205-
<fieldset disabled={createTaskMutation.isPending}>
220+
<Form onSubmit={onSubmit} aria-label="Create AI task">
221+
{Boolean(externalAuthError) && <ErrorAlert error={externalAuthError} />}
222+
223+
<fieldset
224+
className="border border-border border-solid rounded-lg p-4"
225+
disabled={createTaskMutation.isPending}
226+
>
206227
<label htmlFor="prompt" className="sr-only">
207228
Prompt
208229
</label>
@@ -215,7 +236,12 @@ const TaskForm: FC<TaskFormProps> = ({ templates }) => {
215236
text-sm shadow-sm text-content-primary placeholder:text-content-secondary md:text-sm`}
216237
/>
217238
<div className="flex items-center justify-between pt-2">
218-
<Select name="templateID" defaultValue={templates[0].id} required>
239+
<Select
240+
name="templateID"
241+
onValueChange={(value) => setTemplateId(value)}
242+
defaultValue={templates[0].id}
243+
required
244+
>
219245
<SelectTrigger className="w-52 text-xs [&_svg]:size-icon-xs border-0 bg-surface-secondary h-8 px-3">
220246
<SelectValue placeholder="Select a template" />
221247
</SelectTrigger>
@@ -232,15 +258,42 @@ const TaskForm: FC<TaskFormProps> = ({ templates }) => {
232258
</SelectContent>
233259
</Select>
234260

235-
<Button size="sm" type="submit">
236-
<Spinner loading={createTaskMutation.isPending}>
261+
<Button
262+
size="sm"
263+
type="submit"
264+
disabled={!hasAllRequiredExternalAuth}
265+
>
266+
<Spinner
267+
loading={createTaskMutation.isPending || isLoadingExternalAuth}
268+
>
237269
<SendIcon />
238270
</Spinner>
239271
Run task
240272
</Button>
241273
</div>
242274
</fieldset>
243-
</form>
275+
276+
{!hasAllRequiredExternalAuth &&
277+
externalAuth &&
278+
externalAuth.length > 0 && (
279+
<FormSection
280+
title="External Authentication"
281+
description="This template uses external services for authentication."
282+
>
283+
<FormFields>
284+
{externalAuth.map((auth) => (
285+
<ExternalAuthButton
286+
key={auth.id}
287+
auth={auth}
288+
isLoading={externalAuthPollingState === "polling"}
289+
onStartPolling={startPollingExternalAuth}
290+
displayRetry={externalAuthPollingState === "abandoned"}
291+
/>
292+
))}
293+
</FormFields>
294+
</FormSection>
295+
)}
296+
</Form>
244297
);
245298
};
246299

0 commit comments

Comments
 (0)