mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
fix: improve permissions checks in organization settings (#16849)
This commit is contained in:
@@ -35,7 +35,6 @@ test("logins are logged", async ({ page }) => {
|
||||
await page.goto("/audit");
|
||||
const username = users.auditor.username;
|
||||
|
||||
const user = currentUser(page);
|
||||
const loginMessage = `${username} logged in`;
|
||||
// Make sure those things we did all actually show up
|
||||
await resetSearch(page, username);
|
||||
|
||||
@@ -2,7 +2,6 @@ import GroupAdd from "@mui/icons-material/GroupAddOutlined";
|
||||
import { getErrorMessage } from "api/errors";
|
||||
import { groupsByOrganization } from "api/queries/groups";
|
||||
import { organizationsPermissions } from "api/queries/organizations";
|
||||
import { ErrorAlert } from "components/Alert/ErrorAlert";
|
||||
import { Button } from "components/Button/Button";
|
||||
import { EmptyState } from "components/EmptyState/EmptyState";
|
||||
import { displayError } from "components/GlobalSnackbar/utils";
|
||||
@@ -10,6 +9,7 @@ import { Loader } from "components/Loader/Loader";
|
||||
import { SettingsHeader } from "components/SettingsHeader/SettingsHeader";
|
||||
import { Stack } from "components/Stack/Stack";
|
||||
import { useFeatureVisibility } from "modules/dashboard/useFeatureVisibility";
|
||||
import { RequirePermission } from "modules/permissions/RequirePermission";
|
||||
import { type FC, useEffect } from "react";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
import { useQuery } from "react-query";
|
||||
@@ -54,16 +54,26 @@ export const GroupsPage: FC = () => {
|
||||
return <Loader />;
|
||||
}
|
||||
|
||||
const helmet = (
|
||||
<Helmet>
|
||||
<title>{pageTitle("Groups")}</title>
|
||||
</Helmet>
|
||||
);
|
||||
|
||||
const permissions = permissionsQuery.data?.[organization.id];
|
||||
if (!permissions) {
|
||||
return <ErrorAlert error={permissionsQuery.error} />;
|
||||
|
||||
if (!permissions?.viewGroups) {
|
||||
return (
|
||||
<>
|
||||
{helmet}
|
||||
<RequirePermission isFeatureVisible={false} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{pageTitle("Groups")}</title>
|
||||
</Helmet>
|
||||
{helmet}
|
||||
|
||||
<Stack
|
||||
alignItems="baseline"
|
||||
|
||||
@@ -2,8 +2,8 @@ import { getErrorMessage } from "api/errors";
|
||||
import { deleteOrganizationRole, organizationRoles } from "api/queries/roles";
|
||||
import type { Role } from "api/typesGenerated";
|
||||
import { DeleteDialog } from "components/Dialogs/DeleteDialog/DeleteDialog";
|
||||
import { EmptyState } from "components/EmptyState/EmptyState";
|
||||
import { displayError, displaySuccess } from "components/GlobalSnackbar/utils";
|
||||
import { Loader } from "components/Loader/Loader";
|
||||
import { SettingsHeader } from "components/SettingsHeader/SettingsHeader";
|
||||
import { Stack } from "components/Stack/Stack";
|
||||
import { useFeatureVisibility } from "modules/dashboard/useFeatureVisibility";
|
||||
@@ -22,7 +22,7 @@ export const CustomRolesPage: FC = () => {
|
||||
const { organization: organizationName } = useParams() as {
|
||||
organization: string;
|
||||
};
|
||||
const { organizationPermissions } = useOrganizationSettings();
|
||||
const { organization, organizationPermissions } = useOrganizationSettings();
|
||||
|
||||
const [roleToDelete, setRoleToDelete] = useState<Role>();
|
||||
|
||||
@@ -49,65 +49,67 @@ export const CustomRolesPage: FC = () => {
|
||||
}
|
||||
}, [organizationRolesQuery.error]);
|
||||
|
||||
if (!organizationPermissions) {
|
||||
return <Loader />;
|
||||
if (!organization) {
|
||||
return <EmptyState message="Organization not found" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<RequirePermission
|
||||
isFeatureVisible={
|
||||
organizationPermissions.assignOrgRoles ||
|
||||
organizationPermissions.createOrgRoles ||
|
||||
organizationPermissions.viewOrgRoles
|
||||
}
|
||||
>
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{pageTitle("Custom Roles")}</title>
|
||||
<title>
|
||||
{pageTitle(
|
||||
"Custom Roles",
|
||||
organization.display_name || organization.name,
|
||||
)}
|
||||
</title>
|
||||
</Helmet>
|
||||
|
||||
<Stack
|
||||
alignItems="baseline"
|
||||
direction="row"
|
||||
justifyContent="space-between"
|
||||
<RequirePermission
|
||||
isFeatureVisible={organizationPermissions?.viewOrgRoles ?? false}
|
||||
>
|
||||
<SettingsHeader
|
||||
title="Roles"
|
||||
description="Manage roles for this organization."
|
||||
<Stack
|
||||
alignItems="baseline"
|
||||
direction="row"
|
||||
justifyContent="space-between"
|
||||
>
|
||||
<SettingsHeader
|
||||
title="Roles"
|
||||
description="Manage roles for this organization."
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<CustomRolesPageView
|
||||
builtInRoles={builtInRoles}
|
||||
customRoles={customRoles}
|
||||
onDeleteRole={setRoleToDelete}
|
||||
canAssignOrgRole={organizationPermissions?.assignOrgRoles ?? false}
|
||||
canCreateOrgRole={organizationPermissions?.createOrgRoles ?? false}
|
||||
isCustomRolesEnabled={isCustomRolesEnabled}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<CustomRolesPageView
|
||||
builtInRoles={builtInRoles}
|
||||
customRoles={customRoles}
|
||||
onDeleteRole={setRoleToDelete}
|
||||
canAssignOrgRole={organizationPermissions.assignOrgRoles}
|
||||
canCreateOrgRole={organizationPermissions.createOrgRoles}
|
||||
isCustomRolesEnabled={isCustomRolesEnabled}
|
||||
/>
|
||||
|
||||
<DeleteDialog
|
||||
key={roleToDelete?.name}
|
||||
isOpen={roleToDelete !== undefined}
|
||||
confirmLoading={deleteRoleMutation.isLoading}
|
||||
name={roleToDelete?.name ?? ""}
|
||||
entity="role"
|
||||
onCancel={() => setRoleToDelete(undefined)}
|
||||
onConfirm={async () => {
|
||||
try {
|
||||
if (roleToDelete) {
|
||||
await deleteRoleMutation.mutateAsync(roleToDelete.name);
|
||||
<DeleteDialog
|
||||
key={roleToDelete?.name}
|
||||
isOpen={roleToDelete !== undefined}
|
||||
confirmLoading={deleteRoleMutation.isLoading}
|
||||
name={roleToDelete?.name ?? ""}
|
||||
entity="role"
|
||||
onCancel={() => setRoleToDelete(undefined)}
|
||||
onConfirm={async () => {
|
||||
try {
|
||||
if (roleToDelete) {
|
||||
await deleteRoleMutation.mutateAsync(roleToDelete.name);
|
||||
}
|
||||
setRoleToDelete(undefined);
|
||||
await organizationRolesQuery.refetch();
|
||||
displaySuccess("Custom role deleted successfully!");
|
||||
} catch (error) {
|
||||
displayError(
|
||||
getErrorMessage(error, "Failed to delete custom role"),
|
||||
);
|
||||
}
|
||||
setRoleToDelete(undefined);
|
||||
await organizationRolesQuery.refetch();
|
||||
displaySuccess("Custom role deleted successfully!");
|
||||
} catch (error) {
|
||||
displayError(
|
||||
getErrorMessage(error, "Failed to delete custom role"),
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</RequirePermission>
|
||||
}}
|
||||
/>
|
||||
</RequirePermission>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import { Link } from "components/Link/Link";
|
||||
import { Paywall } from "components/Paywall/Paywall";
|
||||
import { useFeatureVisibility } from "modules/dashboard/useFeatureVisibility";
|
||||
import { useOrganizationSettings } from "modules/management/OrganizationSettingsLayout";
|
||||
import { RequirePermission } from "modules/permissions/RequirePermission";
|
||||
import { type FC, useEffect, useState } from "react";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
import { useMutation, useQueries, useQuery, useQueryClient } from "react-query";
|
||||
@@ -31,8 +32,7 @@ export const IdpSyncPage: FC = () => {
|
||||
const { organization: organizationName } = useParams() as {
|
||||
organization: string;
|
||||
};
|
||||
const { organizations } = useOrganizationSettings();
|
||||
const organization = organizations?.find((o) => o.name === organizationName);
|
||||
const { organization, organizationPermissions } = useOrganizationSettings();
|
||||
const [groupField, setGroupField] = useState("");
|
||||
const [roleField, setRoleField] = useState("");
|
||||
|
||||
@@ -80,6 +80,23 @@ export const IdpSyncPage: FC = () => {
|
||||
return <EmptyState message="Organization not found" />;
|
||||
}
|
||||
|
||||
const helmet = (
|
||||
<Helmet>
|
||||
<title>
|
||||
{pageTitle("IdP Sync", organization.display_name || organization.name)}
|
||||
</title>
|
||||
</Helmet>
|
||||
);
|
||||
|
||||
if (!organizationPermissions?.viewIdpSyncSettings) {
|
||||
return (
|
||||
<>
|
||||
{helmet}
|
||||
<RequirePermission isFeatureVisible={false} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const patchGroupSyncSettingsMutation = useMutation(
|
||||
patchGroupSyncSettings(organizationName, queryClient),
|
||||
);
|
||||
@@ -103,9 +120,7 @@ export const IdpSyncPage: FC = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{pageTitle("IdP Sync")}</title>
|
||||
</Helmet>
|
||||
{helmet}
|
||||
|
||||
<div className="flex flex-col gap-12">
|
||||
<header className="flex flex-row items-baseline justify-between">
|
||||
|
||||
@@ -15,6 +15,7 @@ import { displayError, displaySuccess } from "components/GlobalSnackbar/utils";
|
||||
import { Stack } from "components/Stack/Stack";
|
||||
import { useAuthenticated } from "contexts/auth/RequireAuth";
|
||||
import { useOrganizationSettings } from "modules/management/OrganizationSettingsLayout";
|
||||
import { RequirePermission } from "modules/permissions/RequirePermission";
|
||||
import { type FC, useState } from "react";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
import { useMutation, useQuery, useQueryClient } from "react-query";
|
||||
@@ -54,7 +55,7 @@ const OrganizationMembersPage: FC = () => {
|
||||
const [memberToDelete, setMemberToDelete] =
|
||||
useState<OrganizationMemberWithUserData>();
|
||||
|
||||
if (!organization || !organizationPermissions) {
|
||||
if (!organization) {
|
||||
return <EmptyState message="Organization not found" />;
|
||||
}
|
||||
|
||||
@@ -66,6 +67,15 @@ const OrganizationMembersPage: FC = () => {
|
||||
</Helmet>
|
||||
);
|
||||
|
||||
if (!organizationPermissions) {
|
||||
return (
|
||||
<>
|
||||
{helmet}
|
||||
<RequirePermission isFeatureVisible={false} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{helmet}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { EmptyState } from "components/EmptyState/EmptyState";
|
||||
import { useEmbeddedMetadata } from "hooks/useEmbeddedMetadata";
|
||||
import { useDashboard } from "modules/dashboard/useDashboard";
|
||||
import { useOrganizationSettings } from "modules/management/OrganizationSettingsLayout";
|
||||
import { RequirePermission } from "modules/permissions/RequirePermission";
|
||||
import type { FC } from "react";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
import { useQuery } from "react-query";
|
||||
@@ -15,7 +16,7 @@ const OrganizationProvisionersPage: FC = () => {
|
||||
const { organization: organizationName } = useParams() as {
|
||||
organization: string;
|
||||
};
|
||||
const { organization } = useOrganizationSettings();
|
||||
const { organization, organizationPermissions } = useOrganizationSettings();
|
||||
const { entitlements } = useDashboard();
|
||||
const { metadata } = useEmbeddedMetadata();
|
||||
const buildInfoQuery = useQuery(buildInfo(metadata["build-info"]));
|
||||
@@ -25,16 +26,29 @@ const OrganizationProvisionersPage: FC = () => {
|
||||
return <EmptyState message="Organization not found" />;
|
||||
}
|
||||
|
||||
const helmet = (
|
||||
<Helmet>
|
||||
<title>
|
||||
{pageTitle(
|
||||
"Provisioners",
|
||||
organization.display_name || organization.name,
|
||||
)}
|
||||
</title>
|
||||
</Helmet>
|
||||
);
|
||||
|
||||
if (!organizationPermissions?.viewProvisioners) {
|
||||
return (
|
||||
<>
|
||||
{helmet}
|
||||
<RequirePermission isFeatureVisible={false} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>
|
||||
{pageTitle(
|
||||
"Provisioners",
|
||||
organization.display_name || organization.name,
|
||||
)}
|
||||
</title>
|
||||
</Helmet>
|
||||
{helmet}
|
||||
<OrganizationProvisionersPageView
|
||||
showPaywall={!entitlements.features.multiple_organizations.enabled}
|
||||
error={provisionersQuery.error}
|
||||
|
||||
@@ -7,9 +7,12 @@ import { EmptyState } from "components/EmptyState/EmptyState";
|
||||
import { displaySuccess } from "components/GlobalSnackbar/utils";
|
||||
import { displayError } from "components/GlobalSnackbar/utils";
|
||||
import { useOrganizationSettings } from "modules/management/OrganizationSettingsLayout";
|
||||
import { RequirePermission } from "modules/permissions/RequirePermission";
|
||||
import type { FC } from "react";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
import { useMutation, useQueryClient } from "react-query";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { pageTitle } from "utils/page";
|
||||
import { OrganizationSettingsPageView } from "./OrganizationSettingsPageView";
|
||||
|
||||
const OrganizationSettingsPage: FC = () => {
|
||||
@@ -24,36 +27,58 @@ const OrganizationSettingsPage: FC = () => {
|
||||
deleteOrganization(queryClient),
|
||||
);
|
||||
|
||||
if (!organization || !organizationPermissions?.editSettings) {
|
||||
if (!organization) {
|
||||
return <EmptyState message="Organization not found" />;
|
||||
}
|
||||
|
||||
const helmet = (
|
||||
<Helmet>
|
||||
<title>
|
||||
{pageTitle("Settings", organization.display_name || organization.name)}
|
||||
</title>
|
||||
</Helmet>
|
||||
);
|
||||
|
||||
if (!organizationPermissions?.editSettings) {
|
||||
return (
|
||||
<>
|
||||
{helmet}
|
||||
<RequirePermission isFeatureVisible={false} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const error =
|
||||
updateOrganizationMutation.error ?? deleteOrganizationMutation.error;
|
||||
|
||||
return (
|
||||
<OrganizationSettingsPageView
|
||||
organization={organization}
|
||||
error={error}
|
||||
onSubmit={async (values) => {
|
||||
const updatedOrganization =
|
||||
await updateOrganizationMutation.mutateAsync({
|
||||
organizationId: organization.id,
|
||||
req: values,
|
||||
});
|
||||
navigate(`/organizations/${updatedOrganization.name}/settings`);
|
||||
displaySuccess("Organization settings updated.");
|
||||
}}
|
||||
onDeleteOrganization={async () => {
|
||||
try {
|
||||
await deleteOrganizationMutation.mutateAsync(organization.id);
|
||||
displaySuccess("Organization deleted");
|
||||
navigate("/organizations");
|
||||
} catch (error) {
|
||||
displayError(getErrorMessage(error, "Failed to delete organization"));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<>
|
||||
{helmet}
|
||||
<OrganizationSettingsPageView
|
||||
organization={organization}
|
||||
error={error}
|
||||
onSubmit={async (values) => {
|
||||
const updatedOrganization =
|
||||
await updateOrganizationMutation.mutateAsync({
|
||||
organizationId: organization.id,
|
||||
req: values,
|
||||
});
|
||||
navigate(`/organizations/${updatedOrganization.name}/settings`);
|
||||
displaySuccess("Organization settings updated.");
|
||||
}}
|
||||
onDeleteOrganization={async () => {
|
||||
try {
|
||||
await deleteOrganizationMutation.mutateAsync(organization.id);
|
||||
displaySuccess("Organization deleted");
|
||||
navigate("/organizations");
|
||||
} catch (error) {
|
||||
displayError(
|
||||
getErrorMessage(error, "Failed to delete organization"),
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { EmptyState } from "components/EmptyState/EmptyState";
|
||||
import { TabLink, Tabs, TabsList } from "components/Tabs/Tabs";
|
||||
import { useSearchParamsKey } from "hooks/useSearchParamsKey";
|
||||
import { useOrganizationSettings } from "modules/management/OrganizationSettingsLayout";
|
||||
import { RequirePermission } from "modules/permissions/RequirePermission";
|
||||
import type { FC } from "react";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
import { pageTitle } from "utils/page";
|
||||
@@ -16,26 +17,32 @@ const ProvisionersPage: FC = () => {
|
||||
});
|
||||
|
||||
if (!organization || !organizationPermissions?.viewProvisionerJobs) {
|
||||
return <EmptyState message="Organization not found" />;
|
||||
}
|
||||
|
||||
const helmet = (
|
||||
<Helmet>
|
||||
<title>
|
||||
{pageTitle(
|
||||
"Provisioners",
|
||||
organization.display_name || organization.name,
|
||||
)}
|
||||
</title>
|
||||
</Helmet>
|
||||
);
|
||||
|
||||
if (!organizationPermissions?.viewProvisioners) {
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{pageTitle("Provisioners")}</title>
|
||||
</Helmet>
|
||||
<EmptyState message="Organization not found" />
|
||||
{helmet}
|
||||
<RequirePermission isFeatureVisible={false} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>
|
||||
{pageTitle(
|
||||
"Provisioners",
|
||||
organization.display_name || organization.name,
|
||||
)}
|
||||
</title>
|
||||
</Helmet>
|
||||
{helmet}
|
||||
|
||||
<div className="flex flex-col gap-12">
|
||||
<header className="flex flex-row items-baseline justify-between">
|
||||
|
||||
@@ -168,7 +168,6 @@ export const TemplatePageHeader: FC<TemplatePageHeaderProps> = ({
|
||||
onDeleteTemplate,
|
||||
}) => {
|
||||
const getLink = useLinks();
|
||||
const hasIcon = template.icon && template.icon !== "";
|
||||
const templateLink = getLink(
|
||||
linkToTemplate(template.organization_name, template.name),
|
||||
);
|
||||
|
||||
@@ -110,25 +110,6 @@ interface ExternalAuthRowProps {
|
||||
onValidateExternalAuth: () => void;
|
||||
}
|
||||
|
||||
const StyledBadge = styled(Badge)(({ theme }) => ({
|
||||
"& .MuiBadge-badge": {
|
||||
// Make a circular background for the icon. Background provides contrast, with a thin
|
||||
// border to separate it from the avatar image.
|
||||
backgroundColor: `${theme.palette.background.paper}`,
|
||||
borderStyle: "solid",
|
||||
borderColor: `${theme.palette.secondary.main}`,
|
||||
borderWidth: "thin",
|
||||
|
||||
// Override the default minimum sizes, as they are larger than what we want.
|
||||
minHeight: "0px",
|
||||
minWidth: "0px",
|
||||
// Override the default "height", which is usually set to some constant value.
|
||||
height: "auto",
|
||||
// Padding adds some room for the icon to live in.
|
||||
padding: "0.1em",
|
||||
},
|
||||
}));
|
||||
|
||||
const ExternalAuthRow: FC<ExternalAuthRowProps> = ({
|
||||
app,
|
||||
unlinked,
|
||||
|
||||
@@ -22,7 +22,7 @@ interface SidebarProps {
|
||||
}
|
||||
|
||||
export const Sidebar: FC<SidebarProps> = ({ user }) => {
|
||||
const { entitlements, experiments } = useDashboard();
|
||||
const { entitlements } = useDashboard();
|
||||
const showSchedulePage =
|
||||
entitlements.features.advanced_template_scheduling.enabled;
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ import { useDashboard } from "modules/dashboard/useDashboard";
|
||||
import { type FC, useState } from "react";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
import { useMutation, useQuery, useQueryClient } from "react-query";
|
||||
import { useLocation, useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { pageTitle } from "utils/page";
|
||||
import { generateRandomString } from "utils/random";
|
||||
import { ResetPasswordDialog } from "./ResetPasswordDialog";
|
||||
@@ -39,7 +39,6 @@ type UserPageProps = {
|
||||
const UsersPage: FC<UserPageProps> = ({ defaultNewPassword }) => {
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const searchParamsResult = useSearchParams();
|
||||
const { entitlements } = useDashboard();
|
||||
const [searchParams] = searchParamsResult;
|
||||
|
||||
Reference in New Issue
Block a user