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

resolves coder/internal#426
This commit is contained in:
Jaayden Halko
2025-04-03 06:14:25 -04:00
committed by GitHub
parent ab8c437abc
commit b60934b180
12 changed files with 169 additions and 39 deletions
+43
View File
@@ -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";
@@ -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,
@@ -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
>;
@@ -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";
@@ -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];
@@ -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 },
);
@@ -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}
@@ -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 {
@@ -46,7 +47,6 @@ import type {
ExternalAuthPollingState,
} from "./CreateWorkspacePage";
import { ExternalAuthButton } from "./ExternalAuthButton";
import type { CreateWSPermissions } from "./permissions";
export const Language = {
duplicationWarning:
@@ -69,7 +69,7 @@ export interface CreateWorkspacePageViewProps {
parameters: TypesGen.TemplateVersionParameter[];
autofillParameters: AutofillBuildParameter[];
presets: TypesGen.Preset[];
permissions: CreateWSPermissions;
permissions: WorkspacePermissions;
creatingWorkspace: boolean;
onCancel: () => void;
onSubmit: (
@@ -1,16 +0,0 @@
export const createWorkspaceChecks = (organizationId: string) =>
({
createWorkspaceForUser: {
object: {
resource_type: "workspace",
organization_id: organizationId,
owner_id: "*",
},
action: "create",
},
}) as const;
export type CreateWSPermissions = Record<
keyof ReturnType<typeof createWorkspaceChecks>,
boolean
>;
+11 -2
View File
@@ -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,
@@ -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)!;
@@ -85,7 +93,7 @@ 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} />
@@ -93,7 +101,7 @@ export const TemplateLayout: FC<PropsWithChildren> = ({
);
}
if (isLoading || !data) {
if (isLoading || !data || !workspacePermissionsQuery.data) {
return <Loader />;
}
@@ -103,6 +111,7 @@ export const TemplateLayout: FC<PropsWithChildren> = ({
template={data.template}
activeVersion={data.activeVersion}
permissions={data.permissions}
workspacePermissions={workspacePermissionsQuery.data}
onDeleteTemplate={() => {
navigate("/templates");
}}
@@ -13,6 +13,9 @@ const meta: Meta<typeof TemplatePageHeader> = {
permissions: {
canUpdateTemplate: true,
},
workspacePermissions: {
createWorkspaceForUser: true,
},
},
};
@@ -29,6 +32,14 @@ export const CanNotUpdate: Story = {
},
};
export const CannotCreateWorkspace: Story = {
args: {
workspacePermissions: {
createWorkspaceForUser: false,
},
},
};
export const Deprecated: Story = {
args: {
template: {
@@ -158,6 +158,7 @@ export type TemplatePageHeaderProps = {
template: Template;
activeVersion: TemplateVersion;
permissions: AuthorizationResponse;
workspacePermissions: AuthorizationResponse;
onDeleteTemplate: () => void;
};
@@ -165,6 +166,7 @@ export const TemplatePageHeader: FC<TemplatePageHeaderProps> = ({
template,
activeVersion,
permissions,
workspacePermissions,
onDeleteTemplate,
}) => {
const getLink = useLinks();
@@ -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
+13 -1
View File
@@ -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";
@@ -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 (
<>
@@ -39,6 +50,7 @@ export const TemplatesPage: FC = () => {
canCreateTemplates={permissions.createTemplates}
examples={examplesQuery.data}
templates={templatesQuery.data}
workspacePermissions={workspacePermissionsQuery.data}
/>
</>
);
@@ -74,6 +74,11 @@ export const WithTemplates: Story = {
},
],
examples: [],
workspacePermissions: {
[MockTemplate.organization_id]: {
createWorkspaceForUser: true,
},
},
},
};
@@ -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,
@@ -40,6 +40,7 @@ import {
import { useClickableTableRow } from "hooks/useClickableTableRow";
import { PlusIcon } from "lucide-react";
import { linkToTemplate, useLinks } from "modules/navigation";
import type { WorkspacePermissions } from "modules/permissions/workspaces";
import type { FC } from "react";
import { Link, useNavigate } from "react-router-dom";
import { createDayString } from "utils/createDayString";
@@ -87,9 +88,14 @@ const TemplateHelpTooltip: FC = () => {
interface TemplateRowProps {
showOrganizations: boolean;
template: Template;
workspacePermissions: Record<string, WorkspacePermissions> | undefined;
}
const TemplateRow: FC<TemplateRowProps> = ({ showOrganizations, template }) => {
const TemplateRow: FC<TemplateRowProps> = ({
showOrganizations,
template,
workspacePermissions,
}) => {
const getLink = useLinks();
const templatePageLink = getLink(
linkToTemplate(template.organization_name, template.name),
@@ -153,7 +159,8 @@ const TemplateRow: FC<TemplateRowProps> = ({ showOrganizations, template }) => {
<TableCell css={styles.actionCell}>
{template.deprecated ? (
<DeprecatedBadge />
) : (
) : workspacePermissions?.[template.organization_id]
?.createWorkspaceForUser ? (
<MuiButton
size="small"
css={styles.actionButton}
@@ -167,7 +174,7 @@ const TemplateRow: FC<TemplateRowProps> = ({ showOrganizations, template }) => {
>
Create Workspace
</MuiButton>
)}
) : null}
</TableCell>
</TableRow>
);
@@ -180,6 +187,7 @@ export interface TemplatesPageViewProps {
canCreateTemplates: boolean;
examples: TemplateExample[] | undefined;
templates: Template[] | undefined;
workspacePermissions: Record<string, WorkspacePermissions> | undefined;
}
export const TemplatesPageView: FC<TemplatesPageViewProps> = ({
@@ -189,6 +197,7 @@ export const TemplatesPageView: FC<TemplatesPageViewProps> = ({
canCreateTemplates,
examples,
templates,
workspacePermissions,
}) => {
const isLoading = !templates;
const isEmpty = templates && templates.length === 0;
@@ -250,6 +259,7 @@ export const TemplatesPageView: FC<TemplatesPageViewProps> = ({
key={template.id}
showOrganizations={showOrganizations}
template={template}
workspacePermissions={workspacePermissions}
/>
))
)}
@@ -1,3 +1,4 @@
import { workspacePermissionsByOrganization } from "api/queries/organizations";
import { templates } from "api/queries/templates";
import type { Workspace } from "api/typesGenerated";
import { useFilter } from "components/Filter/Filter";
@@ -7,7 +8,7 @@ import { useEffectEvent } from "hooks/hookPolyfills";
import { usePagination } from "hooks/usePagination";
import { useDashboard } from "modules/dashboard/useDashboard";
import { useOrganizationsFilterMenu } from "modules/tableFiltering/options";
import { type FC, useEffect, useState } from "react";
import { type FC, useEffect, useMemo, useState } from "react";
import { Helmet } from "react-helmet-async";
import { useQuery } from "react-query";
import { useSearchParams } from "react-router-dom";
@@ -44,6 +45,24 @@ const WorkspacesPage: FC = () => {
const templatesQuery = useQuery(templates());
const orgPermissionsQuery = useQuery(
workspacePermissionsByOrganization(
templatesQuery.data?.map((template) => template.organization_id),
),
);
// Filter templates based on workspace creation permission
const filteredTemplates = useMemo(() => {
if (!templatesQuery.data || !orgPermissionsQuery.data) {
return templatesQuery.data;
}
return templatesQuery.data.filter((template) => {
const orgPermission = orgPermissionsQuery.data[template.organization_id];
return orgPermission?.createWorkspaceForUser;
});
}, [templatesQuery.data, orgPermissionsQuery.data]);
const filterProps = useWorkspacesFilter({
searchParamsResult,
onFilterChange: () => pagination.goToPage(1),
@@ -90,7 +109,7 @@ const WorkspacesPage: FC = () => {
checkedWorkspaces={checkedWorkspaces}
onCheckChange={setCheckedWorkspaces}
canCheckWorkspaces={canCheckWorkspaces}
templates={templatesQuery.data}
templates={filteredTemplates}
templatesFetchStatus={templatesQuery.status}
workspaces={data?.workspaces}
error={error}