diff --git a/site/src/api/queries/organizations.ts b/site/src/api/queries/organizations.ts index 9befb55e7da28..0f3a7ae8c3c01 100644 --- a/site/src/api/queries/organizations.ts +++ b/site/src/api/queries/organizations.ts @@ -1,6 +1,7 @@ import type { QueryClient } from "react-query"; import { API } from "api/api"; import type { + AuthorizationResponse, CreateOrganizationRequest, UpdateOrganizationRequest, } from "api/typesGenerated"; @@ -133,15 +134,15 @@ export const organizationPermissions = (organizationId: string | undefined) => { return { queryKey: ["organization", organizationId, "permissions"], queryFn: () => + // Only request what we use on individual org settings, members, and group + // pages, which at the moment is whether you can edit the members or roles + // on the members page and whether you can see the create group button on + // the groups page. The edit organization check for the settings page is + // covered by the multi-org query at the moment, and the edit group check + // on the group page is done on the group itself, not the org, so neither + // show up here. API.checkAuthorization({ checks: { - viewMembers: { - object: { - resource_type: "organization_member", - organization_id: organizationId, - }, - action: "read", - }, editMembers: { object: { resource_type: "organization_member", @@ -156,27 +157,6 @@ export const organizationPermissions = (organizationId: string | undefined) => { }, action: "create", }, - viewGroups: { - object: { - resource_type: "group", - organization_id: organizationId, - }, - action: "read", - }, - editOrganization: { - object: { - resource_type: "organization", - organization_id: organizationId, - }, - action: "update", - }, - auditOrganization: { - object: { - resource_type: "audit_log", - organization_id: organizationId, - }, - action: "read", - }, assignOrgRole: { object: { resource_type: "assign_org_role", @@ -188,3 +168,93 @@ export const organizationPermissions = (organizationId: string | undefined) => { }), }; }; + +/** + * Fetch permissions for all provided organizations. + * + * If organizations are undefined, return a disabled query. + */ +export const organizationsPermissions = ( + organizationIds: string[] | undefined, +) => { + if (!organizationIds) { + return { enabled: false }; + } + + return { + queryKey: ["organizations", organizationIds.sort(), "permissions"], + queryFn: async () => { + // Only request what we need for the sidebar, which is one edit permission + // per sub-link (audit, settings, groups, roles, and members pages) that + // tells us whether to show that page, since we only show them if you can + // edit (and not, at the moment if you can only view). + const checks = (organizationId: string) => ({ + editMembers: { + object: { + resource_type: "organization_member", + organization_id: organizationId, + }, + action: "update", + }, + editGroups: { + object: { + resource_type: "group", + organization_id: organizationId, + }, + action: "update", + }, + editOrganization: { + object: { + resource_type: "organization", + organization_id: organizationId, + }, + action: "update", + }, + auditOrganization: { + object: { + resource_type: "audit_log", + organization_id: organizationId, + }, + action: "read", + }, + assignOrgRole: { + object: { + resource_type: "assign_org_role", + organization_id: organizationId, + }, + action: "create", + }, + }); + + // The endpoint takes a flat array, so to avoid collisions prepend each + // check with the org ID (the key can be anything we want). + const prefixedChecks = organizationIds + .map((orgId) => + Object.entries(checks(orgId)).map(([key, val]) => [ + `${orgId}.${key}`, + val, + ]), + ) + .flat(); + + const response = await API.checkAuthorization({ + checks: Object.fromEntries(prefixedChecks), + }); + + // Now we can unflatten by parsing out the org ID from each check. + 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] = value; + return acc; + }, + {} as Record, + ); + }, + }; +}; diff --git a/site/src/pages/ManagementSettingsPage/ManagementSettingsLayout.tsx b/site/src/pages/ManagementSettingsPage/ManagementSettingsLayout.tsx index eb727919984ba..c31bbfe2b54c7 100644 --- a/site/src/pages/ManagementSettingsPage/ManagementSettingsLayout.tsx +++ b/site/src/pages/ManagementSettingsPage/ManagementSettingsLayout.tsx @@ -2,7 +2,7 @@ import { type FC, Suspense } from "react"; import { useQuery } from "react-query"; import { Outlet } from "react-router-dom"; import { deploymentConfig } from "api/queries/deployment"; -import type { Organization } from "api/typesGenerated"; +import type { AuthorizationResponse, Organization } from "api/typesGenerated"; import { Loader } from "components/Loader/Loader"; import { Margins } from "components/Margins/Margins"; import { Stack } from "components/Stack/Stack"; @@ -21,6 +21,20 @@ export const useOrganizationSettings = (): OrganizationSettingsValue => { return { organizations }; }; +/** + * Return true if the user can edit the organization settings or its members. + */ +export const canEditOrganization = ( + permissions: AuthorizationResponse | undefined, +) => { + return ( + permissions !== undefined && + (permissions.editOrganization || + permissions.editMembers || + permissions.editGroups) + ); +}; + /** * A multi-org capable settings page layout. * diff --git a/site/src/pages/ManagementSettingsPage/OrganizationMembersPage.test.tsx b/site/src/pages/ManagementSettingsPage/OrganizationMembersPage.test.tsx index 2bf459d0d0509..9b78cf4e65121 100644 --- a/site/src/pages/ManagementSettingsPage/OrganizationMembersPage.test.tsx +++ b/site/src/pages/ManagementSettingsPage/OrganizationMembersPage.test.tsx @@ -28,7 +28,6 @@ beforeEach(() => { http.post("/api/v2/authcheck", async () => { return HttpResponse.json({ editMembers: true, - viewMembers: true, viewDeploymentValues: true, }); }), diff --git a/site/src/pages/ManagementSettingsPage/OrganizationSettingsPage.test.tsx b/site/src/pages/ManagementSettingsPage/OrganizationSettingsPage.test.tsx new file mode 100644 index 0000000000000..e6107629920a4 --- /dev/null +++ b/site/src/pages/ManagementSettingsPage/OrganizationSettingsPage.test.tsx @@ -0,0 +1,119 @@ +import { screen, within } from "@testing-library/react"; +import { HttpResponse, http } from "msw"; +import { + MockDefaultOrganization, + MockOrganization2, +} from "testHelpers/entities"; +import { + renderWithManagementSettingsLayout, + waitForLoaderToBeRemoved, +} from "testHelpers/renderHelpers"; +import { server } from "testHelpers/server"; +import OrganizationSettingsPage from "./OrganizationSettingsPage"; + +jest.spyOn(console, "error").mockImplementation(() => {}); + +const renderRootPage = async () => { + renderWithManagementSettingsLayout(, { + route: "/organizations", + path: "/organizations/:organization?", + }); + await waitForLoaderToBeRemoved(); +}; + +const renderPage = async (orgName: string) => { + renderWithManagementSettingsLayout(, { + route: `/organizations/${orgName}`, + path: "/organizations/:organization", + }); + await waitForLoaderToBeRemoved(); +}; + +describe("OrganizationSettingsPage", () => { + it("has no organizations", async () => { + server.use( + http.get("/api/v2/organizations", () => { + return HttpResponse.json([]); + }), + http.post("/api/v2/authcheck", async () => { + return HttpResponse.json({ + [`${MockDefaultOrganization.id}.editOrganization`]: true, + viewDeploymentValues: true, + }); + }), + ); + await renderRootPage(); + await screen.findByText("No organizations found"); + }); + + it("has no editable organizations", async () => { + server.use( + http.get("/api/v2/organizations", () => { + return HttpResponse.json([MockDefaultOrganization, MockOrganization2]); + }), + http.post("/api/v2/authcheck", async () => { + return HttpResponse.json({ + viewDeploymentValues: true, + }); + }), + ); + await renderRootPage(); + await screen.findByText("No organizations found"); + }); + + it("redirects to default organization", async () => { + server.use( + http.get("/api/v2/organizations", () => { + // Default always preferred regardless of order. + return HttpResponse.json([MockOrganization2, MockDefaultOrganization]); + }), + http.post("/api/v2/authcheck", async () => { + return HttpResponse.json({ + [`${MockDefaultOrganization.id}.editOrganization`]: true, + [`${MockOrganization2.id}.editOrganization`]: true, + viewDeploymentValues: true, + }); + }), + ); + await renderRootPage(); + const form = screen.getByTestId("org-settings-form"); + expect(within(form).getByRole("textbox", { name: "Name" })).toHaveValue( + MockDefaultOrganization.name, + ); + }); + + it("redirects to non-default organization", async () => { + server.use( + http.get("/api/v2/organizations", () => { + return HttpResponse.json([MockDefaultOrganization, MockOrganization2]); + }), + http.post("/api/v2/authcheck", async () => { + return HttpResponse.json({ + [`${MockOrganization2.id}.editOrganization`]: true, + viewDeploymentValues: true, + }); + }), + ); + await renderRootPage(); + const form = screen.getByTestId("org-settings-form"); + expect(within(form).getByRole("textbox", { name: "Name" })).toHaveValue( + MockOrganization2.name, + ); + }); + + it("cannot find organization", async () => { + server.use( + http.get("/api/v2/organizations", () => { + return HttpResponse.json([MockDefaultOrganization, MockOrganization2]); + }), + http.post("/api/v2/authcheck", async () => { + return HttpResponse.json({ + [`${MockOrganization2.id}.editOrganization`]: true, + viewDeploymentValues: true, + }); + }), + ); + await renderPage("the-endless-void"); + await screen.findByText("Organization not found"); + }); +}); diff --git a/site/src/pages/ManagementSettingsPage/OrganizationSettingsPage.tsx b/site/src/pages/ManagementSettingsPage/OrganizationSettingsPage.tsx index 74fb294e2629a..0b04b3848ed92 100644 --- a/site/src/pages/ManagementSettingsPage/OrganizationSettingsPage.tsx +++ b/site/src/pages/ManagementSettingsPage/OrganizationSettingsPage.tsx @@ -4,13 +4,16 @@ import { Navigate, useNavigate, useParams } from "react-router-dom"; import { updateOrganization, deleteOrganization, - organizationPermissions, + organizationsPermissions, } from "api/queries/organizations"; import type { Organization } from "api/typesGenerated"; import { EmptyState } from "components/EmptyState/EmptyState"; import { displaySuccess } from "components/GlobalSnackbar/utils"; import { Loader } from "components/Loader/Loader"; -import { useOrganizationSettings } from "./ManagementSettingsLayout"; +import { + canEditOrganization, + useOrganizationSettings, +} from "./ManagementSettingsLayout"; import { OrganizationSettingsPageView } from "./OrganizationSettingsPageView"; const OrganizationSettingsPage: FC = () => { @@ -32,37 +35,42 @@ const OrganizationSettingsPage: FC = () => { organizations && organizationName ? getOrganizationByName(organizations, organizationName) : undefined; - const permissionsQuery = useQuery(organizationPermissions(organization?.id)); + const permissionsQuery = useQuery( + organizationsPermissions(organizations?.map((o) => o.id)), + ); - if (!organizations) { + const permissions = permissionsQuery.data; + if (!organizations || !permissions) { return ; } - // Redirect /organizations => /organizations/default-org + // Redirect /organizations => /organizations/default-org, or if they cannot edit + // the default org, then the first org they can edit, if any. if (!organizationName) { - const defaultOrg = getOrganizationByDefault(organizations); - if (defaultOrg) { - return ; + const editableOrg = organizations + .sort((a, b) => { + // Prefer default org (it may not be first). + // JavaScript will happily subtract booleans, but use numbers to keep + // the compiler happy. + return (b.is_default ? 1 : 0) - (a.is_default ? 1 : 0); + }) + .find((org) => canEditOrganization(permissions[org.id])); + if (editableOrg) { + return ; } - // We expect there to always be a default organization. - throw new Error("No default organization found"); + return ; } if (!organization) { return ; } - const permissions = permissionsQuery.data; - if (!permissions) { - return ; - } - const error = updateOrganizationMutation.error ?? deleteOrganizationMutation.error; return ( { @@ -85,8 +93,5 @@ const OrganizationSettingsPage: FC = () => { export default OrganizationSettingsPage; -const getOrganizationByDefault = (organizations: Organization[]) => - organizations.find((org) => org.is_default); - const getOrganizationByName = (organizations: Organization[], name: string) => organizations.find((org) => org.name === name); diff --git a/site/src/pages/ManagementSettingsPage/OrganizationSettingsPageView.tsx b/site/src/pages/ManagementSettingsPage/OrganizationSettingsPageView.tsx index 9f8192117bfb6..be2a5e7cf2365 100644 --- a/site/src/pages/ManagementSettingsPage/OrganizationSettingsPageView.tsx +++ b/site/src/pages/ManagementSettingsPage/OrganizationSettingsPageView.tsx @@ -78,6 +78,7 @@ export const OrganizationSettingsPageView: FC< )} diff --git a/site/src/pages/ManagementSettingsPage/Sidebar.tsx b/site/src/pages/ManagementSettingsPage/Sidebar.tsx index 8e2f80652d93b..6ac55c59b999f 100644 --- a/site/src/pages/ManagementSettingsPage/Sidebar.tsx +++ b/site/src/pages/ManagementSettingsPage/Sidebar.tsx @@ -1,10 +1,13 @@ import type { FC } from "react"; import { useQuery } from "react-query"; -import { useParams } from "react-router-dom"; -import { organizationPermissions } from "api/queries/organizations"; +import { useLocation, useParams } from "react-router-dom"; +import { organizationsPermissions } from "api/queries/organizations"; import { useAuthenticated } from "contexts/auth/RequireAuth"; -import { useOrganizationSettings } from "./ManagementSettingsLayout"; -import { SidebarView } from "./SidebarView"; +import { + canEditOrganization, + useOrganizationSettings, +} from "./ManagementSettingsLayout"; +import { type OrganizationWithPermissions, SidebarView } from "./SidebarView"; /** * A combined deployment settings and organization menu. @@ -14,27 +17,42 @@ import { SidebarView } from "./SidebarView"; * DeploySettingsPage/Sidebar instead. */ export const Sidebar: FC = () => { + const location = useLocation(); const { permissions } = useAuthenticated(); const { organizations } = useOrganizationSettings(); const { organization: organizationName } = useParams() as { organization?: string; }; - // If there is no organization name, the settings page will load, and it will - // redirect to the default organization, so eventually there will always be an - // organization name. - const activeOrganization = organizations?.find( - (o) => o.name === organizationName, - ); - const activeOrgPermissionsQuery = useQuery( - organizationPermissions(activeOrganization?.id), + const orgPermissionsQuery = useQuery( + organizationsPermissions(organizations?.map((o) => o.id)), ); + // Sometimes a user can read an organization but cannot actually do anything + // with it. For now, these are filtered out so you only see organizations you + // can manage in some way. + const editableOrgs = organizations + ?.map((org) => { + return { + ...org, + permissions: orgPermissionsQuery.data?.[org.id], + }; + }) + // TypeScript is not able to infer whether permissions are defined on the + // object even if we explicitly check org.permissions here, so add the `is` + // here to help out (canEditOrganization does the actual check). + .filter((org): org is OrganizationWithPermissions => { + return canEditOrganization(org.permissions); + }); + return ( ); diff --git a/site/src/pages/ManagementSettingsPage/SidebarView.stories.tsx b/site/src/pages/ManagementSettingsPage/SidebarView.stories.tsx index c0d9ea18e9325..44cb0de7fc97a 100644 --- a/site/src/pages/ManagementSettingsPage/SidebarView.stories.tsx +++ b/site/src/pages/ManagementSettingsPage/SidebarView.stories.tsx @@ -12,9 +12,28 @@ const meta: Meta = { component: SidebarView, decorators: [withDashboardProvider], args: { - activeOrganization: undefined, - activeOrgPermissions: undefined, - organizations: [MockOrganization, MockOrganization2], + activeSettings: true, + activeOrganizationName: undefined, + organizations: [ + { + ...MockOrganization, + permissions: { + editOrganization: true, + editMembers: true, + editGroups: true, + auditOrganization: true, + }, + }, + { + ...MockOrganization2, + permissions: { + editOrganization: true, + editMembers: true, + editGroups: true, + auditOrganization: true, + }, + }, + ], permissions: MockPermissions, }, }; @@ -22,7 +41,11 @@ const meta: Meta = { export default meta; type Story = StoryObj; -export const Default: Story = {}; +export const LoadingOrganizations: Story = { + args: { + organizations: undefined, + }, +}; export const NoCreateOrg: Story = { args: { @@ -76,45 +99,126 @@ export const NoPermissions: Story = { }, }; -export const SelectedOrgLoading: Story = { +export const NoSelected: Story = { args: { - activeOrganization: MockOrganization, + activeSettings: false, + }, +}; + +export const SelectedOrgNoMatch: Story = { + args: { + activeOrganizationName: MockOrganization.name, + organizations: [], }, }; export const SelectedOrgAdmin: Story = { args: { - activeOrganization: MockOrganization, - activeOrgPermissions: { - editOrganization: true, - viewMembers: true, - viewGroups: true, - auditOrganization: true, - assignOrgRole: true, - }, + activeOrganizationName: MockOrganization.name, + organizations: [ + { + ...MockOrganization, + permissions: { + editOrganization: true, + editMembers: true, + editGroups: true, + auditOrganization: true, + assignOrgRole: true, + }, + }, + ], }, }; export const SelectedOrgAuditor: Story = { args: { - activeOrganization: MockOrganization, - activeOrgPermissions: { - editOrganization: false, - viewMembers: false, - viewGroups: false, - auditOrganization: true, + activeOrganizationName: MockOrganization.name, + permissions: { + ...MockPermissions, + createOrganization: false, }, + organizations: [ + { + ...MockOrganization, + permissions: { + editOrganization: false, + editMembers: false, + editGroups: false, + auditOrganization: true, + }, + }, + ], }, }; -export const SelectedOrgNoPerms: Story = { +export const SelectedOrgUserAdmin: Story = { args: { - activeOrganization: MockOrganization, - activeOrgPermissions: { - editOrganization: false, - viewMembers: false, - viewGroups: false, - auditOrganization: false, + activeOrganizationName: MockOrganization.name, + permissions: { + ...MockPermissions, + createOrganization: false, }, + organizations: [ + { + ...MockOrganization, + permissions: { + editOrganization: false, + editMembers: true, + editGroups: true, + auditOrganization: false, + }, + }, + ], + }, +}; + +export const MultiOrgAdminAndUserAdmin: Story = { + args: { + organizations: [ + { + ...MockOrganization, + permissions: { + editOrganization: false, + editMembers: false, + editGroups: false, + auditOrganization: true, + }, + }, + { + ...MockOrganization2, + permissions: { + editOrganization: false, + editMembers: true, + editGroups: true, + auditOrganization: false, + }, + }, + ], + }, +}; + +export const SelectedMultiOrgAdminAndUserAdmin: Story = { + args: { + activeOrganizationName: MockOrganization2.name, + organizations: [ + { + ...MockOrganization, + permissions: { + editOrganization: false, + editMembers: false, + editGroups: false, + auditOrganization: true, + }, + }, + { + ...MockOrganization2, + permissions: { + editOrganization: false, + editMembers: true, + editGroups: true, + auditOrganization: false, + }, + }, + ], }, }; diff --git a/site/src/pages/ManagementSettingsPage/SidebarView.tsx b/site/src/pages/ManagementSettingsPage/SidebarView.tsx index 87a11e43329c6..f5a31e2bce514 100644 --- a/site/src/pages/ManagementSettingsPage/SidebarView.tsx +++ b/site/src/pages/ManagementSettingsPage/SidebarView.tsx @@ -13,19 +13,17 @@ import { type ClassName, useClassName } from "hooks/useClassName"; import { useDashboard } from "modules/dashboard/useDashboard"; import { linkToAuditing, linkToUsers, withFilter } from "modules/navigation"; +export interface OrganizationWithPermissions extends Organization { + permissions: AuthorizationResponse; +} + interface SidebarProps { - /** - * The active org if an org is being viewed. If there is no active - * organization, assume one of the deployment settings pages are being viewed. - */ - activeOrganization: Organization | undefined; - /** - * The permissions for the active org or undefined if still fetching (or if - * there is no active org). - */ - activeOrgPermissions: AuthorizationResponse | undefined; - /** The list of organizations or undefined if still fetching. */ - organizations: Organization[] | undefined; + /** True if a settings page is being viewed. */ + activeSettings: boolean; + /** The active org name, if any. Overrides activeSettings. */ + activeOrganizationName: string | undefined; + /** Organizations and their permissions or undefined if still fetching. */ + organizations: OrganizationWithPermissions[] | undefined; /** Site-wide permissions. */ permissions: AuthorizationResponse; } @@ -33,44 +31,25 @@ interface SidebarProps { /** * A combined deployment settings and organization menu. */ -export const SidebarView: FC = (props) => { +export const SidebarView: FC = ({ + activeSettings, + activeOrganizationName, + organizations, + permissions, +}) => { // TODO: Do something nice to scroll to the active org. return (
Deployment
+ - {props.organizations ? ( - <> -
Organizations
- {props.permissions.createOrganization && ( - } - > - New organization - - )} - {props.organizations.map((org) => { - const orgActive = - Boolean(props.activeOrganization) && - org.name === props.activeOrganization?.name; - return ( - - ); - })} - - ) : ( - - )}
); }; @@ -89,15 +68,16 @@ interface DeploymentSettingsNavigationProps { * Menu items are shown based on the permissions. If organizations can be * viewed, groups are skipped since they will show under each org instead. */ -const DeploymentSettingsNavigation: FC = ( - props, -) => { +const DeploymentSettingsNavigation: FC = ({ + active, + permissions, +}) => { return (
= ( > Deployment - {props.active && ( + {active && ( - {props.permissions.viewDeploymentValues && ( + {permissions.viewDeploymentValues && ( General )} - {props.permissions.viewAllLicenses && ( + {permissions.viewAllLicenses && ( Licenses )} - {props.permissions.editDeploymentValues && ( + {permissions.editDeploymentValues && ( Appearance )} - {props.permissions.viewDeploymentValues && ( + {permissions.viewDeploymentValues && ( User Authentication )} - {props.permissions.viewDeploymentValues && ( + {permissions.viewDeploymentValues && ( External Authentication @@ -133,27 +113,27 @@ const DeploymentSettingsNavigation: FC = ( Network )} {/* All users can view workspace regions. */} Workspace Proxies - {props.permissions.viewDeploymentValues && ( + {permissions.viewDeploymentValues && ( Security )} - {props.permissions.viewDeploymentValues && ( + {permissions.viewDeploymentValues && ( Observability )} - {props.permissions.viewAllUsers && ( + {permissions.viewAllUsers && ( Users )} - {props.permissions.viewAnyAuditLog && ( + {permissions.viewAnyAuditLog && ( Auditing @@ -168,70 +148,118 @@ function urlForSubpage(organizationName: string, subpage: string = ""): string { return `/organizations/${organizationName}/${subpage}`; } +interface OrganizationsSettingsNavigationProps { + /** The active org name if an org is being viewed. */ + activeOrganizationName: string | undefined; + /** Organizations and their permissions or undefined if still fetching. */ + organizations: OrganizationWithPermissions[] | undefined; + /** Site-wide permissions. */ + permissions: AuthorizationResponse; +} + +/** + * Displays navigation for all organizations and a create organization link. + * + * If organizations or their permissions are still loading, show a loader. + * + * If there are no organizations and the user does not have the create org + * permission, nothing is displayed. + */ +const OrganizationsSettingsNavigation: FC< + OrganizationsSettingsNavigationProps +> = ({ activeOrganizationName, organizations, permissions }) => { + // Wait for organizations and their permissions to load in. + if (!organizations) { + return ; + } + + if (organizations.length <= 0 && !permissions.createOrganization) { + return null; + } + + return ( + <> +
Organizations
+ {permissions.createOrganization && ( + } + > + New organization + + )} + {organizations.map((org) => ( + + ))} + + ); +}; + interface OrganizationSettingsNavigationProps { + /** Whether this organization is currently selected. */ active: boolean; - organization: Organization; - permissions: AuthorizationResponse | undefined; + /** The organization to display in the navigation. */ + organization: OrganizationWithPermissions; } /** - * Displays navigation for an organization. + * Displays navigation for a single organization. * * If inactive, no sub-menu items will be shown, just the organization name. * - * If active, it will show a loader until the permissions are defined, then the - * sub-menu items are shown as appropriate. + * If active, it will show sub-menu items based on the permissions. */ const OrganizationSettingsNavigation: FC< OrganizationSettingsNavigationProps -> = (props) => { +> = ({ active, organization }) => { const { experiments } = useDashboard(); return ( <> } > - {props.organization.display_name} + {organization.display_name} - {props.active && !props.permissions && } - {props.active && props.permissions && ( + {active && ( - {props.permissions.editOrganization && ( - + {organization.permissions.editOrganization && ( + Organization settings )} - {props.permissions.viewMembers && ( + {organization.permissions.editMembers && ( Members )} - {props.permissions.viewGroups && ( + {organization.permissions.editGroups && ( Groups )} - {props.permissions.assignOrgRole && + {organization.permissions.assignOrgRole && experiments.includes("custom-roles") && ( Roles @@ -239,11 +267,11 @@ const OrganizationSettingsNavigation: FC< {/* For now redirect to the site-wide audit page with the organization pre-filled into the filter. Based on user feedback we might want to serve a copy of the audit page or even delete this link. */} - {props.permissions.auditOrganization && ( + {organization.permissions.auditOrganization && ( Auditing