Skip to content

Commit b60934b

Browse files
authored
chore: hide workspace creation UI for users without permission (#16871)
resolves coder/internal#426
1 parent ab8c437 commit b60934b

12 files changed

+169
-39
lines changed

site/src/api/queries/organizations.ts

+43
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,11 @@ import {
1313
type OrganizationPermissions,
1414
organizationPermissionChecks,
1515
} from "modules/permissions/organizations";
16+
import {
17+
type WorkspacePermissionName,
18+
type WorkspacePermissions,
19+
workspacePermissionChecks,
20+
} from "modules/permissions/workspaces";
1621
import type { QueryClient } from "react-query";
1722
import { meKey } from "./users";
1823

@@ -299,6 +304,44 @@ export const organizationsPermissions = (
299304
};
300305
};
301306

307+
export const workspacePermissionsByOrganization = (
308+
organizationIds: string[] | undefined,
309+
) => {
310+
if (!organizationIds) {
311+
return { enabled: false };
312+
}
313+
314+
return {
315+
queryKey: ["workspaces", organizationIds.sort(), "permissions"],
316+
queryFn: async () => {
317+
const prefixedChecks = organizationIds.flatMap((orgId) =>
318+
Object.entries(workspacePermissionChecks(orgId)).map(([key, val]) => [
319+
`${orgId}.${key}`,
320+
val,
321+
]),
322+
);
323+
324+
const response = await API.checkAuthorization({
325+
checks: Object.fromEntries(prefixedChecks),
326+
});
327+
328+
return Object.entries(response).reduce(
329+
(acc, [key, value]) => {
330+
const index = key.indexOf(".");
331+
const orgId = key.substring(0, index);
332+
const perm = key.substring(index + 1);
333+
if (!acc[orgId]) {
334+
acc[orgId] = {};
335+
}
336+
acc[orgId][perm as WorkspacePermissionName] = value;
337+
return acc;
338+
},
339+
{} as Record<string, Partial<WorkspacePermissions>>,
340+
) as Record<string, WorkspacePermissions>;
341+
},
342+
};
343+
};
344+
302345
export const getOrganizationIdpSyncClaimFieldValuesKey = (
303346
organization: string,
304347
field: string,
+20
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
export const workspacePermissionChecks = (organizationId: string) =>
2+
({
3+
createWorkspaceForUser: {
4+
object: {
5+
resource_type: "workspace",
6+
organization_id: organizationId,
7+
owner_id: "*",
8+
},
9+
action: "create",
10+
},
11+
}) as const;
12+
13+
export type WorkspacePermissions = Record<
14+
keyof ReturnType<typeof workspacePermissionChecks>,
15+
boolean
16+
>;
17+
18+
export type WorkspacePermissionName = keyof ReturnType<
19+
typeof workspacePermissionChecks
20+
>;

site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx

+6-3
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@ import { Loader } from "components/Loader/Loader";
1717
import { useAuthenticated } from "contexts/auth/RequireAuth";
1818
import { useEffectEvent } from "hooks/hookPolyfills";
1919
import { useDashboard } from "modules/dashboard/useDashboard";
20+
import {
21+
type WorkspacePermissions,
22+
workspacePermissionChecks,
23+
} from "modules/permissions/workspaces";
2024
import { generateWorkspaceName } from "modules/workspaces/generateWorkspaceName";
2125
import { type FC, useCallback, useEffect, useRef, useState } from "react";
2226
import { Helmet } from "react-helmet-async";
@@ -26,7 +30,6 @@ import { pageTitle } from "utils/page";
2630
import type { AutofillBuildParameter } from "utils/richParameters";
2731
import { paramsUsedToCreateWorkspace } from "utils/workspace";
2832
import { CreateWorkspacePageView } from "./CreateWorkspacePageView";
29-
import { type CreateWSPermissions, createWorkspaceChecks } from "./permissions";
3033

3134
export const createWorkspaceModes = ["form", "auto", "duplicate"] as const;
3235
export type CreateWorkspaceMode = (typeof createWorkspaceModes)[number];
@@ -64,7 +67,7 @@ const CreateWorkspacePage: FC = () => {
6467
const permissionsQuery = useQuery(
6568
templateQuery.data
6669
? checkAuthorization({
67-
checks: createWorkspaceChecks(templateQuery.data.organization_id),
70+
checks: workspacePermissionChecks(templateQuery.data.organization_id),
6871
})
6972
: { enabled: false },
7073
);
@@ -206,7 +209,7 @@ const CreateWorkspacePage: FC = () => {
206209
externalAuthPollingState={externalAuthPollingState}
207210
startPollingExternalAuth={startPollingExternalAuth}
208211
hasAllRequiredExternalAuth={hasAllRequiredExternalAuth}
209-
permissions={permissionsQuery.data as CreateWSPermissions}
212+
permissions={permissionsQuery.data as WorkspacePermissions}
210213
parameters={realizedParameters as TemplateVersionParameter[]}
211214
presets={templateVersionPresetsQuery.data ?? []}
212215
creatingWorkspace={createWorkspaceMutation.isLoading}

site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx

+2-2
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import { Stack } from "components/Stack/Stack";
2828
import { Switch } from "components/Switch/Switch";
2929
import { UserAutocomplete } from "components/UserAutocomplete/UserAutocomplete";
3030
import { type FormikContextType, useFormik } from "formik";
31+
import type { WorkspacePermissions } from "modules/permissions/workspaces";
3132
import { generateWorkspaceName } from "modules/workspaces/generateWorkspaceName";
3233
import { type FC, useCallback, useEffect, useMemo, useState } from "react";
3334
import {
@@ -46,7 +47,6 @@ import type {
4647
ExternalAuthPollingState,
4748
} from "./CreateWorkspacePage";
4849
import { ExternalAuthButton } from "./ExternalAuthButton";
49-
import type { CreateWSPermissions } from "./permissions";
5050

5151
export const Language = {
5252
duplicationWarning:
@@ -69,7 +69,7 @@ export interface CreateWorkspacePageViewProps {
6969
parameters: TypesGen.TemplateVersionParameter[];
7070
autofillParameters: AutofillBuildParameter[];
7171
presets: TypesGen.Preset[];
72-
permissions: CreateWSPermissions;
72+
permissions: WorkspacePermissions;
7373
creatingWorkspace: boolean;
7474
onCancel: () => void;
7575
onSubmit: (

site/src/pages/CreateWorkspacePage/permissions.ts

-16
This file was deleted.

site/src/pages/TemplatePage/TemplateLayout.tsx

+11-2
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import { API } from "api/api";
2+
import { checkAuthorization } from "api/queries/authCheck";
23
import type { AuthorizationRequest } from "api/typesGenerated";
34
import { ErrorAlert } from "components/Alert/ErrorAlert";
45
import { Loader } from "components/Loader/Loader";
56
import { Margins } from "components/Margins/Margins";
67
import { TabLink, Tabs, TabsList } from "components/Tabs/Tabs";
8+
import { workspacePermissionChecks } from "modules/permissions/workspaces";
79
import {
810
type FC,
911
type PropsWithChildren,
@@ -77,6 +79,12 @@ export const TemplateLayout: FC<PropsWithChildren> = ({
7779
queryKey: ["template", templateName],
7880
queryFn: () => fetchTemplate(organizationName, templateName),
7981
});
82+
const workspacePermissionsQuery = useQuery(
83+
checkAuthorization({
84+
checks: workspacePermissionChecks(organizationName),
85+
}),
86+
);
87+
8088
const location = useLocation();
8189
const paths = location.pathname.split("/");
8290
const activeTab = paths.at(-1) === templateName ? "summary" : paths.at(-1)!;
@@ -85,15 +93,15 @@ export const TemplateLayout: FC<PropsWithChildren> = ({
8593
const shouldShowInsights =
8694
data?.permissions?.canUpdateTemplate || data?.permissions?.canReadInsights;
8795

88-
if (error) {
96+
if (error || workspacePermissionsQuery.error) {
8997
return (
9098
<div css={{ margin: 16 }}>
9199
<ErrorAlert error={error} />
92100
</div>
93101
);
94102
}
95103

96-
if (isLoading || !data) {
104+
if (isLoading || !data || !workspacePermissionsQuery.data) {
97105
return <Loader />;
98106
}
99107

@@ -103,6 +111,7 @@ export const TemplateLayout: FC<PropsWithChildren> = ({
103111
template={data.template}
104112
activeVersion={data.activeVersion}
105113
permissions={data.permissions}
114+
workspacePermissions={workspacePermissionsQuery.data}
106115
onDeleteTemplate={() => {
107116
navigate("/templates");
108117
}}

site/src/pages/TemplatePage/TemplatePageHeader.stories.tsx

+11
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ const meta: Meta<typeof TemplatePageHeader> = {
1313
permissions: {
1414
canUpdateTemplate: true,
1515
},
16+
workspacePermissions: {
17+
createWorkspaceForUser: true,
18+
},
1619
},
1720
};
1821

@@ -29,6 +32,14 @@ export const CanNotUpdate: Story = {
2932
},
3033
};
3134

35+
export const CannotCreateWorkspace: Story = {
36+
args: {
37+
workspacePermissions: {
38+
createWorkspaceForUser: false,
39+
},
40+
},
41+
};
42+
3243
export const Deprecated: Story = {
3344
args: {
3445
template: {

site/src/pages/TemplatePage/TemplatePageHeader.tsx

+13-10
Original file line numberDiff line numberDiff line change
@@ -158,13 +158,15 @@ export type TemplatePageHeaderProps = {
158158
template: Template;
159159
activeVersion: TemplateVersion;
160160
permissions: AuthorizationResponse;
161+
workspacePermissions: AuthorizationResponse;
161162
onDeleteTemplate: () => void;
162163
};
163164

164165
export const TemplatePageHeader: FC<TemplatePageHeaderProps> = ({
165166
template,
166167
activeVersion,
167168
permissions,
169+
workspacePermissions,
168170
onDeleteTemplate,
169171
}) => {
170172
const getLink = useLinks();
@@ -177,16 +179,17 @@ export const TemplatePageHeader: FC<TemplatePageHeaderProps> = ({
177179
<PageHeader
178180
actions={
179181
<>
180-
{!template.deprecated && (
181-
<Button
182-
variant="contained"
183-
startIcon={<AddIcon />}
184-
component={RouterLink}
185-
to={`${templateLink}/workspace`}
186-
>
187-
Create Workspace
188-
</Button>
189-
)}
182+
{!template.deprecated &&
183+
workspacePermissions.createWorkspaceForUser && (
184+
<Button
185+
variant="contained"
186+
startIcon={<AddIcon />}
187+
component={RouterLink}
188+
to={`${templateLink}/workspace`}
189+
>
190+
Create Workspace
191+
</Button>
192+
)}
190193

191194
{permissions.canUpdateTemplate && (
192195
<TemplateMenu

site/src/pages/TemplatesPage/TemplatesPage.tsx

+13-1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { workspacePermissionsByOrganization } from "api/queries/organizations";
12
import { templateExamples, templates } from "api/queries/templates";
23
import { useFilter } from "components/Filter/Filter";
34
import { useAuthenticated } from "contexts/auth/RequireAuth";
@@ -25,7 +26,17 @@ export const TemplatesPage: FC = () => {
2526
...templateExamples(),
2627
enabled: permissions.createTemplates,
2728
});
28-
const error = templatesQuery.error || examplesQuery.error;
29+
30+
const workspacePermissionsQuery = useQuery(
31+
workspacePermissionsByOrganization(
32+
templatesQuery.data?.map((template) => template.organization_id),
33+
),
34+
);
35+
36+
const error =
37+
templatesQuery.error ||
38+
examplesQuery.error ||
39+
workspacePermissionsQuery.error;
2940

3041
return (
3142
<>
@@ -39,6 +50,7 @@ export const TemplatesPage: FC = () => {
3950
canCreateTemplates={permissions.createTemplates}
4051
examples={examplesQuery.data}
4152
templates={templatesQuery.data}
53+
workspacePermissions={workspacePermissionsQuery.data}
4254
/>
4355
</>
4456
);

site/src/pages/TemplatesPage/TemplatesPageView.stories.tsx

+16
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,11 @@ export const WithTemplates: Story = {
7474
},
7575
],
7676
examples: [],
77+
workspacePermissions: {
78+
[MockTemplate.organization_id]: {
79+
createWorkspaceForUser: true,
80+
},
81+
},
7782
},
7883
};
7984

@@ -84,6 +89,17 @@ export const MultipleOrganizations: Story = {
8489
},
8590
};
8691

92+
export const CannotCreateWorkspaces: Story = {
93+
args: {
94+
...WithTemplates.args,
95+
workspacePermissions: {
96+
[MockTemplate.organization_id]: {
97+
createWorkspaceForUser: false,
98+
},
99+
},
100+
},
101+
};
102+
87103
export const WithFilteredAllTemplates: Story = {
88104
args: {
89105
...WithTemplates.args,

0 commit comments

Comments
 (0)