diff --git a/site/src/api/queries/organizations.ts b/site/src/api/queries/organizations.ts index 2dc0402d75484..b0e25a985bd0f 100644 --- a/site/src/api/queries/organizations.ts +++ b/site/src/api/queries/organizations.ts @@ -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>, + ) as Record; + }, + }; +}; + export const getOrganizationIdpSyncClaimFieldValuesKey = ( organization: string, field: string, diff --git a/site/src/modules/permissions/workspaces.ts b/site/src/modules/permissions/workspaces.ts new file mode 100644 index 0000000000000..9ebb75d4790de --- /dev/null +++ b/site/src/modules/permissions/workspaces.ts @@ -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, + boolean +>; + +export type WorkspacePermissionName = keyof ReturnType< + typeof workspacePermissionChecks +>; diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx index 150a79bd69487..26f1808b83152 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx @@ -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} diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx index 6dab8de306a10..660580b5b80b8 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx @@ -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: ( diff --git a/site/src/pages/CreateWorkspacePage/permissions.ts b/site/src/pages/CreateWorkspacePage/permissions.ts deleted file mode 100644 index 07bad5031ddc2..0000000000000 --- a/site/src/pages/CreateWorkspacePage/permissions.ts +++ /dev/null @@ -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, - boolean ->; diff --git a/site/src/pages/TemplatePage/TemplateLayout.tsx b/site/src/pages/TemplatePage/TemplateLayout.tsx index cf1c3f84ddd55..93d25d6f591db 100644 --- a/site/src/pages/TemplatePage/TemplateLayout.tsx +++ b/site/src/pages/TemplatePage/TemplateLayout.tsx @@ -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 = ({ 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 = ({ const shouldShowInsights = data?.permissions?.canUpdateTemplate || data?.permissions?.canReadInsights; - if (error) { + if (error || workspacePermissionsQuery.error) { return (
@@ -93,7 +101,7 @@ export const TemplateLayout: FC = ({ ); } - if (isLoading || !data) { + if (isLoading || !data || !workspacePermissionsQuery.data) { return ; } @@ -103,6 +111,7 @@ export const TemplateLayout: FC = ({ template={data.template} activeVersion={data.activeVersion} permissions={data.permissions} + workspacePermissions={workspacePermissionsQuery.data} onDeleteTemplate={() => { navigate("/templates"); }} diff --git a/site/src/pages/TemplatePage/TemplatePageHeader.stories.tsx b/site/src/pages/TemplatePage/TemplatePageHeader.stories.tsx index e7bf7db389666..4acd28446631f 100644 --- a/site/src/pages/TemplatePage/TemplatePageHeader.stories.tsx +++ b/site/src/pages/TemplatePage/TemplatePageHeader.stories.tsx @@ -13,6 +13,9 @@ const meta: Meta = { 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: { diff --git a/site/src/pages/TemplatePage/TemplatePageHeader.tsx b/site/src/pages/TemplatePage/TemplatePageHeader.tsx index 7bb1d9e54a4c2..1d70379e75f43 100644 --- a/site/src/pages/TemplatePage/TemplatePageHeader.tsx +++ b/site/src/pages/TemplatePage/TemplatePageHeader.tsx @@ -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 = ({ template, activeVersion, permissions, + workspacePermissions, onDeleteTemplate, }) => { const getLink = useLinks(); @@ -177,16 +179,17 @@ export const TemplatePageHeader: FC = ({ - {!template.deprecated && ( - - )} + {!template.deprecated && + workspacePermissions.createWorkspaceForUser && ( + + )} {permissions.canUpdateTemplate && ( { ...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} /> ); diff --git a/site/src/pages/TemplatesPage/TemplatesPageView.stories.tsx b/site/src/pages/TemplatesPage/TemplatesPageView.stories.tsx index 7572f39b4b365..bed6b72c5d719 100644 --- a/site/src/pages/TemplatesPage/TemplatesPageView.stories.tsx +++ b/site/src/pages/TemplatesPage/TemplatesPageView.stories.tsx @@ -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, diff --git a/site/src/pages/TemplatesPage/TemplatesPageView.tsx b/site/src/pages/TemplatesPage/TemplatesPageView.tsx index 5d2a512980d8e..fbf3743043c08 100644 --- a/site/src/pages/TemplatesPage/TemplatesPageView.tsx +++ b/site/src/pages/TemplatesPage/TemplatesPageView.tsx @@ -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 | undefined; } -const TemplateRow: FC = ({ showOrganizations, template }) => { +const TemplateRow: FC = ({ + showOrganizations, + template, + workspacePermissions, +}) => { const getLink = useLinks(); const templatePageLink = getLink( linkToTemplate(template.organization_name, template.name), @@ -153,7 +159,8 @@ const TemplateRow: FC = ({ showOrganizations, template }) => { {template.deprecated ? ( - ) : ( + ) : workspacePermissions?.[template.organization_id] + ?.createWorkspaceForUser ? ( = ({ showOrganizations, template }) => { > Create Workspace - )} + ) : null} ); @@ -180,6 +187,7 @@ export interface TemplatesPageViewProps { canCreateTemplates: boolean; examples: TemplateExample[] | undefined; templates: Template[] | undefined; + workspacePermissions: Record | undefined; } export const TemplatesPageView: FC = ({ @@ -189,6 +197,7 @@ export const TemplatesPageView: FC = ({ canCreateTemplates, examples, templates, + workspacePermissions, }) => { const isLoading = !templates; const isEmpty = templates && templates.length === 0; @@ -250,6 +259,7 @@ export const TemplatesPageView: FC = ({ key={template.id} showOrganizations={showOrganizations} template={template} + workspacePermissions={workspacePermissions} /> )) )} diff --git a/site/src/pages/WorkspacesPage/WorkspacesPage.tsx b/site/src/pages/WorkspacesPage/WorkspacesPage.tsx index e94ccbbd86605..62fb8c1132200 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPage.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPage.tsx @@ -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}