Skip to content

chore: hide workspace creation UI for users without permission #16871

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 13 commits into from
Apr 3, 2025
43 changes: 43 additions & 0 deletions site/src/api/queries/organizations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ import {
type OrganizationPermissions,
organizationPermissionChecks,
} from "modules/permissions/organizations";
import {
type WorkspacePermissionName,
type WorkspacePermissions,
workspacePermissionChecks,
} from "modules/permissions/workspaces";
import type { QueryClient } from "react-query";
import { meKey } from "./users";

Expand Down Expand Up @@ -299,6 +304,44 @@ export const organizationsPermissions = (
};
};

export const workspacePermissionsByOrganization = (
organizationIds: string[] | undefined,
) => {
if (!organizationIds) {
return { enabled: false };
}

return {
queryKey: ["workspaces", organizationIds.sort(), "permissions"],
queryFn: async () => {
const prefixedChecks = organizationIds.flatMap((orgId) =>
Object.entries(workspacePermissionChecks(orgId)).map(([key, val]) => [
`${orgId}.${key}`,
val,
]),
);

const response = await API.checkAuthorization({
checks: Object.fromEntries(prefixedChecks),
});

return Object.entries(response).reduce(
(acc, [key, value]) => {
const index = key.indexOf(".");
const orgId = key.substring(0, index);
const perm = key.substring(index + 1);
if (!acc[orgId]) {
acc[orgId] = {};
}
acc[orgId][perm as WorkspacePermissionName] = value;
return acc;
},
{} as Record<string, Partial<WorkspacePermissions>>,
) as Record<string, WorkspacePermissions>;
},
};
};

export const getOrganizationIdpSyncClaimFieldValuesKey = (
organization: string,
field: string,
Expand Down
20 changes: 20 additions & 0 deletions site/src/modules/permissions/workspaces.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
export const workspacePermissionChecks = (organizationId: string) =>
({
createWorkspaceForUser: {
object: {
resource_type: "workspace",
organization_id: organizationId,
owner_id: "*",
},
action: "create",
},
}) as const;

export type WorkspacePermissions = Record<
keyof ReturnType<typeof workspacePermissionChecks>,
boolean
>;

export type WorkspacePermissionName = keyof ReturnType<
typeof workspacePermissionChecks
>;
9 changes: 6 additions & 3 deletions site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ import { Loader } from "components/Loader/Loader";
import { useAuthenticated } from "contexts/auth/RequireAuth";
import { useEffectEvent } from "hooks/hookPolyfills";
import { useDashboard } from "modules/dashboard/useDashboard";
import {
type WorkspacePermissions,
workspacePermissionChecks,
} from "modules/permissions/workspaces";
import { generateWorkspaceName } from "modules/workspaces/generateWorkspaceName";
import { type FC, useCallback, useEffect, useRef, useState } from "react";
import { Helmet } from "react-helmet-async";
Expand All @@ -26,7 +30,6 @@ import { pageTitle } from "utils/page";
import type { AutofillBuildParameter } from "utils/richParameters";
import { paramsUsedToCreateWorkspace } from "utils/workspace";
import { CreateWorkspacePageView } from "./CreateWorkspacePageView";
import { type CreateWSPermissions, createWorkspaceChecks } from "./permissions";

export const createWorkspaceModes = ["form", "auto", "duplicate"] as const;
export type CreateWorkspaceMode = (typeof createWorkspaceModes)[number];
Expand Down Expand Up @@ -64,7 +67,7 @@ const CreateWorkspacePage: FC = () => {
const permissionsQuery = useQuery(
templateQuery.data
? checkAuthorization({
checks: createWorkspaceChecks(templateQuery.data.organization_id),
checks: workspacePermissionChecks(templateQuery.data.organization_id),
})
: { enabled: false },
);
Expand Down Expand Up @@ -206,7 +209,7 @@ const CreateWorkspacePage: FC = () => {
externalAuthPollingState={externalAuthPollingState}
startPollingExternalAuth={startPollingExternalAuth}
hasAllRequiredExternalAuth={hasAllRequiredExternalAuth}
permissions={permissionsQuery.data as CreateWSPermissions}
permissions={permissionsQuery.data as WorkspacePermissions}
parameters={realizedParameters as TemplateVersionParameter[]}
presets={templateVersionPresetsQuery.data ?? []}
creatingWorkspace={createWorkspaceMutation.isLoading}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,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 { WorkspacePermissions } from "modules/permissions/workspaces";
import { generateWorkspaceName } from "modules/workspaces/generateWorkspaceName";
import { type FC, useCallback, useEffect, useMemo, useState } from "react";
import {
Expand All @@ -46,7 +47,6 @@ import type {
ExternalAuthPollingState,
} from "./CreateWorkspacePage";
import { ExternalAuthButton } from "./ExternalAuthButton";
import type { CreateWSPermissions } from "./permissions";

export const Language = {
duplicationWarning:
Expand All @@ -69,7 +69,7 @@ export interface CreateWorkspacePageViewProps {
parameters: TypesGen.TemplateVersionParameter[];
autofillParameters: AutofillBuildParameter[];
presets: TypesGen.Preset[];
permissions: CreateWSPermissions;
permissions: WorkspacePermissions;
creatingWorkspace: boolean;
onCancel: () => void;
onSubmit: (
Expand Down
16 changes: 0 additions & 16 deletions site/src/pages/CreateWorkspacePage/permissions.ts

This file was deleted.

13 changes: 11 additions & 2 deletions site/src/pages/TemplatePage/TemplateLayout.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { API } from "api/api";
import { checkAuthorization } from "api/queries/authCheck";
import type { AuthorizationRequest } from "api/typesGenerated";
import { ErrorAlert } from "components/Alert/ErrorAlert";
import { Loader } from "components/Loader/Loader";
import { Margins } from "components/Margins/Margins";
import { TabLink, Tabs, TabsList } from "components/Tabs/Tabs";
import { workspacePermissionChecks } from "modules/permissions/workspaces";
import {
type FC,
type PropsWithChildren,
Expand Down Expand Up @@ -77,6 +79,12 @@ export const TemplateLayout: FC<PropsWithChildren> = ({
queryKey: ["template", templateName],
queryFn: () => fetchTemplate(organizationName, templateName),
});
const workspacePermissionsQuery = useQuery(
checkAuthorization({
checks: workspacePermissionChecks(organizationName),
}),
);

const location = useLocation();
const paths = location.pathname.split("/");
const activeTab = paths.at(-1) === templateName ? "summary" : paths.at(-1)!;
Expand All @@ -85,15 +93,15 @@ export const TemplateLayout: FC<PropsWithChildren> = ({
const shouldShowInsights =
data?.permissions?.canUpdateTemplate || data?.permissions?.canReadInsights;

if (error) {
if (error || workspacePermissionsQuery.error) {
return (
<div css={{ margin: 16 }}>
<ErrorAlert error={error} />
</div>
);
}

if (isLoading || !data) {
if (isLoading || !data || !workspacePermissionsQuery.data) {
return <Loader />;
}

Expand All @@ -103,6 +111,7 @@ export const TemplateLayout: FC<PropsWithChildren> = ({
template={data.template}
activeVersion={data.activeVersion}
permissions={data.permissions}
workspacePermissions={workspacePermissionsQuery.data}
onDeleteTemplate={() => {
navigate("/templates");
}}
Expand Down
11 changes: 11 additions & 0 deletions site/src/pages/TemplatePage/TemplatePageHeader.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ const meta: Meta<typeof TemplatePageHeader> = {
permissions: {
canUpdateTemplate: true,
},
workspacePermissions: {
createWorkspaceForUser: true,
},
},
};

Expand All @@ -29,6 +32,14 @@ export const CanNotUpdate: Story = {
},
};

export const CannotCreateWorkspace: Story = {
args: {
workspacePermissions: {
createWorkspaceForUser: false,
},
},
};

export const Deprecated: Story = {
args: {
template: {
Expand Down
23 changes: 13 additions & 10 deletions site/src/pages/TemplatePage/TemplatePageHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -158,13 +158,15 @@ export type TemplatePageHeaderProps = {
template: Template;
activeVersion: TemplateVersion;
permissions: AuthorizationResponse;
workspacePermissions: AuthorizationResponse;
onDeleteTemplate: () => void;
};

export const TemplatePageHeader: FC<TemplatePageHeaderProps> = ({
template,
activeVersion,
permissions,
workspacePermissions,
onDeleteTemplate,
}) => {
const getLink = useLinks();
Expand All @@ -177,16 +179,17 @@ export const TemplatePageHeader: FC<TemplatePageHeaderProps> = ({
<PageHeader
actions={
<>
{!template.deprecated && (
<Button
variant="contained"
startIcon={<AddIcon />}
component={RouterLink}
to={`${templateLink}/workspace`}
>
Create Workspace
</Button>
)}
{!template.deprecated &&
workspacePermissions.createWorkspaceForUser && (
<Button
variant="contained"
startIcon={<AddIcon />}
component={RouterLink}
to={`${templateLink}/workspace`}
>
Create Workspace
</Button>
)}

{permissions.canUpdateTemplate && (
<TemplateMenu
Expand Down
14 changes: 13 additions & 1 deletion site/src/pages/TemplatesPage/TemplatesPage.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { workspacePermissionsByOrganization } from "api/queries/organizations";
import { templateExamples, templates } from "api/queries/templates";
import { useFilter } from "components/Filter/Filter";
import { useAuthenticated } from "contexts/auth/RequireAuth";
Expand Down Expand Up @@ -25,7 +26,17 @@ export const TemplatesPage: FC = () => {
...templateExamples(),
enabled: permissions.createTemplates,
});
const error = templatesQuery.error || examplesQuery.error;

const workspacePermissionsQuery = useQuery(
workspacePermissionsByOrganization(
templatesQuery.data?.map((template) => template.organization_id),
),
);

const error =
templatesQuery.error ||
examplesQuery.error ||
workspacePermissionsQuery.error;

return (
<>
Expand All @@ -39,6 +50,7 @@ export const TemplatesPage: FC = () => {
canCreateTemplates={permissions.createTemplates}
examples={examplesQuery.data}
templates={templatesQuery.data}
workspacePermissions={workspacePermissionsQuery.data}
/>
</>
);
Expand Down
16 changes: 16 additions & 0 deletions site/src/pages/TemplatesPage/TemplatesPageView.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,11 @@ export const WithTemplates: Story = {
},
],
examples: [],
workspacePermissions: {
[MockTemplate.organization_id]: {
createWorkspaceForUser: true,
},
},
},
};

Expand All @@ -84,6 +89,17 @@ export const MultipleOrganizations: Story = {
},
};

export const CannotCreateWorkspaces: Story = {
args: {
...WithTemplates.args,
workspacePermissions: {
[MockTemplate.organization_id]: {
createWorkspaceForUser: false,
},
},
},
};

export const WithFilteredAllTemplates: Story = {
args: {
...WithTemplates.args,
Expand Down
Loading
Loading