Skip to content

Commit 9028717

Browse files
authored
fix: disable auto-create if external auth requirements aren't met (coder#12538)
1 parent ef26ad9 commit 9028717

File tree

6 files changed

+124
-26
lines changed

6 files changed

+124
-26
lines changed

site/jest.setup.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ jest.mock("contexts/useProxyLatency", () => ({
4040
global.scrollTo = jest.fn();
4141

4242
window.HTMLElement.prototype.scrollIntoView = jest.fn();
43+
window.open = jest.fn();
4344

4445
// Polyfill the getRandomValues that is used on utils/random.ts
4546
Object.defineProperty(global.self, "crypto", {

site/src/api/errors.ts

Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,26 @@ export type ApiError = AxiosError<ApiErrorResponse> & {
2424
};
2525

2626
export const isApiError = (err: unknown): err is ApiError => {
27-
return axios.isAxiosError(err) && err.response !== undefined;
27+
return (
28+
axios.isAxiosError(err) &&
29+
err.response !== undefined &&
30+
isApiErrorResponse(err.response.data)
31+
);
32+
};
33+
34+
export const isApiErrorResponse = (err: unknown): err is ApiErrorResponse => {
35+
return (
36+
typeof err === "object" &&
37+
err !== null &&
38+
"message" in err &&
39+
typeof err.message === "string" &&
40+
(!("detail" in err) ||
41+
err.detail === undefined ||
42+
typeof err.detail === "string") &&
43+
(!("validations" in err) ||
44+
err.validations === undefined ||
45+
Array.isArray(err.validations))
46+
);
2847
};
2948

3049
export const hasApiFieldErrors = (error: ApiError): boolean =>
@@ -67,6 +86,9 @@ export const getErrorMessage = (
6786
if (isApiError(error) && error.response.data.message) {
6887
return error.response.data.message;
6988
}
89+
if (isApiErrorResponse(error)) {
90+
return error.message;
91+
}
7092
// if error is a non-empty string
7193
if (error && typeof error === "string") {
7294
return error;
@@ -88,9 +110,15 @@ export const getValidationErrorMessage = (error: unknown): string => {
88110
return validationErrors.map((error) => error.detail).join("\n");
89111
};
90112

91-
export const getErrorDetail = (error: unknown): string | undefined | null =>
92-
isApiError(error)
93-
? error.response.data.detail
94-
: error instanceof Error
95-
? `Please check the developer console for more details.`
96-
: null;
113+
export const getErrorDetail = (error: unknown): string | undefined | null => {
114+
if (error instanceof Error) {
115+
return "Please check the developer console for more details.";
116+
}
117+
if (isApiError(error)) {
118+
return error.response.data.detail;
119+
}
120+
if (isApiErrorResponse(error)) {
121+
return error.detail;
122+
}
123+
return null;
124+
};

site/src/components/Alert/ErrorAlert.stories.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,6 @@ export const WithActionAndDismiss: Story = {
5555

5656
export const WithNonApiError: Story = {
5757
args: {
58-
error: new Error("Non API error here"),
58+
error: new Error("Everything has gone horribly, devastatingly wrong."),
5959
},
6060
};

site/src/pages/CreateWorkspacePage/CreateWorkspacePage.test.tsx

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -251,15 +251,48 @@ describe("CreateWorkspacePage", () => {
251251
MockOrganization.id,
252252
"me",
253253
expect.objectContaining({
254-
template_id: MockTemplate.id,
254+
template_version_id: MockTemplate.active_version_id,
255255
rich_parameter_values: [
256-
expect.objectContaining({ name: param, value: paramValue }),
256+
expect.objectContaining({
257+
name: param,
258+
source: "url",
259+
value: paramValue,
260+
}),
257261
],
258262
}),
259263
);
260264
});
261265
});
262266

267+
it("disables mode=auto if a required external auth provider is not connected", async () => {
268+
const param = "first_parameter";
269+
const paramValue = "It works!";
270+
const createWorkspaceSpy = jest.spyOn(API, "createWorkspace");
271+
272+
const externalAuthSpy = jest
273+
.spyOn(API, "getTemplateVersionExternalAuth")
274+
.mockResolvedValue([MockTemplateVersionExternalAuthGithub]);
275+
276+
renderWithAuth(<CreateWorkspacePage />, {
277+
route:
278+
"/templates/" +
279+
MockTemplate.name +
280+
`/workspace?param.${param}=${paramValue}&mode=auto`,
281+
path: "/templates/:template/workspace",
282+
});
283+
await waitForLoaderToBeRemoved();
284+
285+
const warning =
286+
"This template requires an external authentication provider that is not connected.";
287+
expect(await screen.findByText(warning)).toBeInTheDocument();
288+
expect(createWorkspaceSpy).not.toBeCalled();
289+
290+
// We don't need to do this on any other tests out of hundreds of very, very,
291+
// very similar tests, and yet here, I find it to be absolutely necessary for
292+
// some reason that I certainly do not understand. - Kayla
293+
externalAuthSpy.mockReset();
294+
});
295+
263296
it("auto create a workspace if uses mode=auto and version=version-id", async () => {
264297
const param = "first_parameter";
265298
const paramValue = "It works!";

site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx

Lines changed: 46 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { Helmet } from "react-helmet-async";
33
import { useMutation, useQuery, useQueryClient } from "react-query";
44
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
55
import { getUserParameters } from "api/api";
6+
import type { ApiErrorResponse } from "api/errors";
67
import { checkAuthorization } from "api/queries/authCheck";
78
import {
89
richParameters,
@@ -37,11 +38,14 @@ const CreateWorkspacePage: FC = () => {
3738
const { user: me, organizationId } = useAuthenticated();
3839
const navigate = useNavigate();
3940
const [searchParams, setSearchParams] = useSearchParams();
40-
const mode = getWorkspaceMode(searchParams);
41-
const customVersionId = searchParams.get("version") ?? undefined;
4241
const { experiments } = useDashboard();
4342

43+
const customVersionId = searchParams.get("version") ?? undefined;
4444
const defaultName = searchParams.get("name");
45+
const disabledParams = searchParams.get("disable_params")?.split(",");
46+
const [mode, setMode] = useState(() => getWorkspaceMode(searchParams));
47+
const [autoCreateError, setAutoCreateError] =
48+
useState<ApiErrorResponse | null>(null);
4549

4650
const queryClient = useQueryClient();
4751
const autoCreateWorkspaceMutation = useMutation(
@@ -126,37 +130,69 @@ const CreateWorkspacePage: FC = () => {
126130
}
127131
});
128132

129-
const autoStartReady =
130-
mode === "auto" && (!autofillEnabled || userParametersQuery.isSuccess);
133+
const hasAllRequiredExternalAuth = Boolean(
134+
!isLoadingExternalAuth &&
135+
externalAuth?.every((auth) => auth.optional || auth.authenticated),
136+
);
137+
138+
let autoCreateReady =
139+
mode === "auto" &&
140+
(!autofillEnabled || userParametersQuery.isSuccess) &&
141+
hasAllRequiredExternalAuth;
142+
143+
// `mode=auto` was set, but a prerequisite has failed, and so auto-mode should be abandoned.
144+
if (
145+
mode === "auto" &&
146+
!isLoadingExternalAuth &&
147+
!hasAllRequiredExternalAuth
148+
) {
149+
// Prevent suddenly resuming auto-mode if the user connects to all of the required
150+
// external auth providers.
151+
setMode("form");
152+
// Ensure this is always false, so that we don't ever let `automateWorkspaceCreation`
153+
// fire when we're trying to disable it.
154+
autoCreateReady = false;
155+
// Show an error message to explain _why_ the workspace was not created automatically.
156+
const subject =
157+
externalAuth?.length === 1
158+
? "an external authentication provider that is"
159+
: "external authentication providers that are";
160+
setAutoCreateError({
161+
message: `This template requires ${subject} not connected.`,
162+
detail:
163+
"Auto-creation has been disabled. Please connect all required external authentication providers before continuing.",
164+
});
165+
}
166+
131167
useEffect(() => {
132-
if (autoStartReady) {
168+
if (autoCreateReady) {
133169
void automateWorkspaceCreation();
134170
}
135-
}, [automateWorkspaceCreation, autoStartReady]);
171+
}, [automateWorkspaceCreation, autoCreateReady]);
136172

137173
return (
138174
<>
139175
<Helmet>
140176
<title>{pageTitle(title)}</title>
141177
</Helmet>
142178
{loadFormDataError && <ErrorAlert error={loadFormDataError} />}
143-
{isLoadingFormData ||
144-
isLoadingExternalAuth ||
145-
autoCreateWorkspaceMutation.isLoading ? (
179+
{isLoadingFormData || isLoadingExternalAuth || autoCreateReady ? (
146180
<Loader />
147181
) : (
148182
<CreateWorkspacePageView
149183
mode={mode}
150184
defaultName={defaultName}
185+
disabledParams={disabledParams}
151186
defaultOwner={me}
152187
autofillParameters={autofillParameters}
153-
error={createWorkspaceMutation.error}
188+
error={createWorkspaceMutation.error || autoCreateError}
154189
resetMutation={createWorkspaceMutation.reset}
155190
template={templateQuery.data!}
156191
versionId={realizedVersionId}
157192
externalAuth={externalAuth ?? []}
158193
externalAuthPollingState={externalAuthPollingState}
159194
startPollingExternalAuth={startPollingExternalAuth}
195+
hasAllRequiredExternalAuth={hasAllRequiredExternalAuth}
160196
permissions={permissionsQuery.data as CreateWSPermissions}
161197
parameters={realizedParameters as TemplateVersionParameter[]}
162198
creatingWorkspace={createWorkspaceMutation.isLoading}

site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import FormHelperText from "@mui/material/FormHelperText";
44
import TextField from "@mui/material/TextField";
55
import { type FormikContextType, useFormik } from "formik";
66
import { type FC, useEffect, useState, useMemo, useCallback } from "react";
7-
import { useSearchParams } from "react-router-dom";
87
import * as Yup from "yup";
98
import type * as TypesGen from "api/typesGenerated";
109
import { Alert } from "components/Alert/Alert";
@@ -51,15 +50,17 @@ export const Language = {
5150

5251
export interface CreateWorkspacePageViewProps {
5352
mode: CreateWorkspaceMode;
53+
defaultName?: string | null;
54+
disabledParams?: string[];
5455
error: unknown;
5556
resetMutation: () => void;
56-
defaultName?: string | null;
5757
defaultOwner: TypesGen.User;
5858
template: TypesGen.Template;
5959
versionId?: string;
6060
externalAuth: TypesGen.TemplateVersionExternalAuth[];
6161
externalAuthPollingState: ExternalAuthPollingState;
6262
startPollingExternalAuth: () => void;
63+
hasAllRequiredExternalAuth: boolean;
6364
parameters: TypesGen.TemplateVersionParameter[];
6465
autofillParameters: AutofillBuildParameter[];
6566
permissions: CreateWSPermissions;
@@ -73,9 +74,10 @@ export interface CreateWorkspacePageViewProps {
7374

7475
export const CreateWorkspacePageView: FC<CreateWorkspacePageViewProps> = ({
7576
mode,
77+
defaultName,
78+
disabledParams,
7679
error,
7780
resetMutation,
78-
defaultName,
7981
defaultOwner,
8082
template,
8183
versionId,
@@ -90,8 +92,6 @@ export const CreateWorkspacePageView: FC<CreateWorkspacePageViewProps> = ({
9092
onCancel,
9193
}) => {
9294
const [owner, setOwner] = useState(defaultOwner);
93-
const [searchParams] = useSearchParams();
94-
const disabledParamsList = searchParams?.get("disable_params")?.split(",");
9595
const requiresExternalAuth = externalAuth.some((auth) => !auth.authenticated);
9696
const [suggestedName, setSuggestedName] = useState(() =>
9797
generateWorkspaceName(),
@@ -285,7 +285,7 @@ export const CreateWorkspacePageView: FC<CreateWorkspacePageViewProps> = ({
285285
const parameterField = `rich_parameter_values.${index}`;
286286
const parameterInputName = `${parameterField}.value`;
287287
const isDisabled =
288-
disabledParamsList?.includes(
288+
disabledParams?.includes(
289289
parameter.name.toLowerCase().replace(/ /g, "_"),
290290
) || creatingWorkspace;
291291

0 commit comments

Comments
 (0)