Skip to content

Commit 3c8ae73

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 80d1188 commit 3c8ae73

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";
@@ -39,6 +43,7 @@ import { Link as RouterLink } from "react-router-dom";
3943
import TextareaAutosize from "react-textarea-autosize";
4044
import { pageTitle } from "utils/page";
4145
import { relativeTime } from "utils/time";
46+
import { ExternalAuthButton } from "../CreateWorkspacePage/ExternalAuthButton";
4247
import { type UserOption, UsersCombobox } from "./UsersCombobox";
4348

4449
type TasksFilter = {
@@ -160,6 +165,21 @@ const TaskForm: FC<TaskFormProps> = ({ templates }) => {
160165
const { user } = useAuthenticated();
161166
const queryClient = useQueryClient();
162167

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

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

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

0 commit comments

Comments
 (0)