fix: improve permissions checks in organization settings (#16849)

This commit is contained in:
ケイラ
2025-03-07 14:45:29 -07:00
committed by GitHub
parent 092c129de0
commit ec11f11ac5
12 changed files with 193 additions and 132 deletions
-1
View File
@@ -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);
+16 -6
View File
@@ -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,
+1 -1
View File
@@ -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;
+1 -2
View File
@@ -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;