mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
chore: hide workspace creation UI for users without permission (#16871)
resolves coder/internal#426
This commit is contained in:
@@ -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
|
||||
>;
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user