From f3828b430e17c3feb1a51535aeda5dca533318f8 Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Wed, 5 Feb 2025 22:41:33 +0000 Subject: [PATCH 01/21] oh man what I have gotten myself into --- site/src/api/queries/organizations.ts | 110 ++---------- .../management/OrganizationSettingsLayout.tsx | 57 +++++-- .../management/OrganizationSidebar.tsx | 62 ++----- .../OrganizationSidebarView.stories.tsx | 158 +++--------------- .../management/OrganizationSidebarView.tsx | 115 +++++-------- .../management/organizationPermissions.tsx | 73 ++++++++ .../NotificationsPage/storybookUtils.ts | 4 +- site/src/pages/GroupsPage/GroupsPage.tsx | 17 +- .../pages/GroupsPage/GroupsPageProvider.tsx | 11 +- .../CustomRolesPage/CreateEditRolePage.tsx | 10 +- .../CustomRolesPage/CustomRolesPage.tsx | 19 +-- .../DefaultOrganizationRedirect.tsx | 38 +++++ .../OrganizationMembersPage.tsx | 22 +-- .../OrganizationMembersPageView.tsx | 1 - .../OrganizationSettingsPage.stories.tsx | 4 +- .../OrganizationSettingsPage.tsx | 26 +-- .../OrganizationSummaryPageView.stories.tsx | 23 --- .../OrganizationSummaryPageView.tsx | 49 ------ site/src/router.tsx | 6 +- site/src/testHelpers/entities.ts | 25 +++ site/src/testHelpers/storybook.tsx | 8 +- 21 files changed, 329 insertions(+), 509 deletions(-) create mode 100644 site/src/modules/management/organizationPermissions.tsx create mode 100644 site/src/pages/OrganizationSettingsPage/DefaultOrganizationRedirect.tsx delete mode 100644 site/src/pages/OrganizationSettingsPage/OrganizationSummaryPageView.stories.tsx delete mode 100644 site/src/pages/OrganizationSettingsPage/OrganizationSummaryPageView.tsx diff --git a/site/src/api/queries/organizations.ts b/site/src/api/queries/organizations.ts index 33ef19f0d2654..904a5702aaf36 100644 --- a/site/src/api/queries/organizations.ts +++ b/site/src/api/queries/organizations.ts @@ -1,6 +1,5 @@ import { API } from "api/api"; import type { - AuthorizationResponse, CreateOrganizationRequest, GroupSyncSettings, RoleSyncSettings, @@ -8,6 +7,11 @@ import type { } from "api/typesGenerated"; import type { QueryClient } from "react-query"; import { meKey } from "./users"; +import { + organizationPermissionChecks, + type OrganizationPermissions, + type OrganizationPermissionName, +} from "modules/management/organizationPermissions"; export const createOrganization = (queryClient: QueryClient) => { return { @@ -197,53 +201,6 @@ export const patchRoleSyncSettings = ( }; }; -/** - * Fetch permissions for a single organization. - * - * If the ID is undefined, return a disabled query. - */ -export const organizationPermissions = (organizationId: string | undefined) => { - if (!organizationId) { - return { enabled: false }; - } - 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 on the - // members page, create roles on the roles page, and create groups 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: { - editMembers: { - object: { - resource_type: "organization_member", - organization_id: organizationId, - }, - action: "update", - }, - createGroup: { - object: { - resource_type: "group", - organization_id: organizationId, - }, - action: "create", - }, - assignOrgRole: { - object: { - resource_type: "assign_org_role", - organization_id: organizationId, - }, - action: "create", - }, - }, - }), - }; -}; - /** * Fetch permissions for all provided organizations. * @@ -263,58 +220,13 @@ export const organizationsPermissions = ( // per sub-link (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", - }, - assignOrgRole: { - object: { - resource_type: "assign_org_role", - organization_id: organizationId, - }, - action: "create", - }, - viewProvisioners: { - object: { - resource_type: "provisioner_daemon", - organization_id: organizationId, - }, - action: "read", - }, - viewIdpSyncSettings: { - object: { - resource_type: "idpsync_settings", - organization_id: organizationId, - }, - action: "read", - }, - }); // 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.flatMap((orgId) => - Object.entries(checks(orgId)).map(([key, val]) => [ - `${orgId}.${key}`, - val, - ]), + Object.entries(organizationPermissionChecks(orgId)).map( + ([key, val]) => [`${orgId}.${key}`, val], + ), ); const response = await API.checkAuthorization({ @@ -330,11 +242,11 @@ export const organizationsPermissions = ( if (!acc[orgId]) { acc[orgId] = {}; } - acc[orgId][perm] = value; + acc[orgId][perm as OrganizationPermissionName] = value; return acc; }, - {} as Record, - ); + {} as Record>, + ) as Record; }, }; }; diff --git a/site/src/modules/management/OrganizationSettingsLayout.tsx b/site/src/modules/management/OrganizationSettingsLayout.tsx index 3e9e2537a0ec2..f7f7da3e674c9 100644 --- a/site/src/modules/management/OrganizationSettingsLayout.tsx +++ b/site/src/modules/management/OrganizationSettingsLayout.tsx @@ -1,20 +1,23 @@ +import { organizationsPermissions } from "api/queries/organizations"; import type { AuthorizationResponse, Organization } from "api/typesGenerated"; +import { ErrorAlert } from "components/Alert/ErrorAlert"; import { Avatar } from "components/Avatar/Avatar"; import { Breadcrumb, BreadcrumbItem, - BreadcrumbLink, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator, } from "components/Breadcrumb/Breadcrumb"; -import { FeatureStageBadge } from "components/FeatureStageBadge/FeatureStageBadge"; import { Loader } from "components/Loader/Loader"; import { useAuthenticated } from "contexts/auth/RequireAuth"; import { RequirePermission } from "contexts/auth/RequirePermission"; import { useDashboard } from "modules/dashboard/useDashboard"; +import NotFoundPage from "pages/404Page/404Page"; import { type FC, Suspense, createContext, useContext } from "react"; +import { useQuery } from "react-query"; import { Outlet, useParams } from "react-router-dom"; +import type { OrganizationPermissions } from "./organizationPermissions"; export const OrganizationSettingsContext = createContext< OrganizationSettingsValue | undefined @@ -22,7 +25,9 @@ export const OrganizationSettingsContext = createContext< type OrganizationSettingsValue = Readonly<{ organizations: readonly Organization[]; + permissionsByOrganizationId: Record; organization?: Organization; + organizationPermissions?: OrganizationPermissions; }>; export const useOrganizationSettings = (): OrganizationSettingsValue => { @@ -37,16 +42,19 @@ export const useOrganizationSettings = (): OrganizationSettingsValue => { }; /** - * Return true if the user can edit the organization settings or its members. + * Checks if the user can view or edit members or groups for the organization + * that produced the given OrganizationPermissions. */ -export const canEditOrganization = ( - permissions: AuthorizationResponse | undefined, -) => { +const canViewOrganization = ( + permissions: OrganizationPermissions | undefined, +): permissions is OrganizationPermissions => { return ( permissions !== undefined && (permissions.editOrganization || permissions.editMembers || - permissions.editGroups) + permissions.viewMembers || + permissions.editGroups || + permissions.viewGroups) ); }; @@ -57,19 +65,46 @@ const OrganizationSettingsLayout: FC = () => { organization?: string; }; - const canViewOrganizationSettingsPage = - permissions.viewDeploymentValues || permissions.editAnyOrganization; + const canViewOrganizationSettingsPage = permissions.editAnyOrganization; const organization = orgName ? organizations.find((org) => org.name === orgName) : undefined; + const orgPermissionsQuery = useQuery( + organizationsPermissions(organizations?.map((o) => o.id)), + ); + + if (orgPermissionsQuery.isLoading) { + return ; + } + + if (!orgPermissionsQuery.data) { + return ; + } + + const viewableOrganizations = organizations.filter((org) => + canViewOrganization(orgPermissionsQuery.data?.[org.id]), + ); + + // It's currently up to each individual page to show an empty state if there + // is no matching organization. This is weird and we should probably fix it + // eventually, but if we handled it here it would break the /new route, and + // refactoring to fix _that_ is a non-trivial amount of work. + const organizationPermissions = + organization && orgPermissionsQuery.data?.[organization.id]; + if (organization && !canViewOrganization(organizationPermissions)) { + return ; + } + return (
@@ -95,7 +130,7 @@ const OrganizationSettingsLayout: FC = () => { fallback={organization.display_name} src={organization.icon} /> - {organization?.name} + {organization.display_name} diff --git a/site/src/modules/management/OrganizationSidebar.tsx b/site/src/modules/management/OrganizationSidebar.tsx index 8ef14f9baf165..40f29146a3981 100644 --- a/site/src/modules/management/OrganizationSidebar.tsx +++ b/site/src/modules/management/OrganizationSidebar.tsx @@ -1,59 +1,25 @@ -import { organizationsPermissions } from "api/queries/organizations"; import { useAuthenticated } from "contexts/auth/RequireAuth"; -import { - canEditOrganization, - useOrganizationSettings, -} from "modules/management/OrganizationSettingsLayout"; +import { useOrganizationSettings } from "modules/management/OrganizationSettingsLayout"; import type { FC } from "react"; -import { useQuery } from "react-query"; -import { useParams } from "react-router-dom"; -import { - OrganizationSidebarView, - type OrganizationWithPermissions, -} from "./OrganizationSidebarView"; +import { OrganizationSidebarView } from "./OrganizationSidebarView"; +import { Sidebar as BaseSidebar } from "components/Sidebar/Sidebar"; /** - * A combined deployment settings and organization menu. - * - * This should only be used with multi-org support. If multi-org support is - * disabled or not licensed, this is the wrong sidebar to use. See - * DeploySettingsPage/Sidebar instead. + * Sidebar for the OrganizationSettingsLayout */ export const OrganizationSidebar: FC = () => { const { permissions } = useAuthenticated(); - const { organizations } = useOrganizationSettings(); - const { organization: organizationName } = useParams() as { - organization?: string; - }; - - 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); - }); - - const organization = editableOrgs?.find((o) => o.name === organizationName); + const { organizations, organization, organizationPermissions } = + useOrganizationSettings(); return ( - + + + ); }; diff --git a/site/src/modules/management/OrganizationSidebarView.stories.tsx b/site/src/modules/management/OrganizationSidebarView.stories.tsx index 4f1b17a27c181..f6c7c204c451c 100644 --- a/site/src/modules/management/OrganizationSidebarView.stories.tsx +++ b/site/src/modules/management/OrganizationSidebarView.stories.tsx @@ -1,9 +1,11 @@ import type { Meta, StoryObj } from "@storybook/react"; import { expect, userEvent, waitFor, within } from "@storybook/test"; import { + MockNoOrganizationPermissions, MockNoPermissions, MockOrganization, MockOrganization2, + MockOrganizationPermissions, MockPermissions, } from "testHelpers/entities"; import { withDashboardProvider } from "testHelpers/storybook"; @@ -16,26 +18,7 @@ const meta: Meta = { parameters: { showOrganizations: true }, args: { activeOrganization: undefined, - organizations: [ - { - ...MockOrganization, - permissions: { - editOrganization: true, - editMembers: true, - editGroups: true, - auditOrganization: true, - }, - }, - { - ...MockOrganization2, - permissions: { - editOrganization: true, - editMembers: true, - editGroups: true, - auditOrganization: true, - }, - }, - ], + organizations: [MockOrganization, MockOrganization2], permissions: MockPermissions, }, }; @@ -51,10 +34,8 @@ export const LoadingOrganizations: Story = { export const NoCreateOrg: Story = { args: { - activeOrganization: { - ...MockOrganization, - permissions: { createOrganization: false }, - }, + activeOrganization: MockOrganization, + orgPermissions: MockNoOrganizationPermissions, permissions: { ...MockPermissions, createOrganization: false, @@ -73,23 +54,15 @@ export const NoCreateOrg: Story = { export const OverflowDropdown: Story = { args: { - activeOrganization: { - ...MockOrganization, - permissions: { createOrganization: true }, - }, + activeOrganization: MockOrganization, + orgPermissions: MockOrganizationPermissions, permissions: { ...MockPermissions, createOrganization: true, }, organizations: [ - { - ...MockOrganization, - permissions: {}, - }, - { - ...MockOrganization2, - permissions: {}, - }, + MockOrganization, + MockOrganization2, { id: "my-organization-3-id", name: "my-organization-3", @@ -99,7 +72,6 @@ export const OverflowDropdown: Story = { created_at: "", updated_at: "", is_default: false, - permissions: {}, }, { id: "my-organization-4-id", @@ -110,7 +82,6 @@ export const OverflowDropdown: Story = { created_at: "", updated_at: "", is_default: false, - permissions: {}, }, { id: "my-organization-5-id", @@ -121,7 +92,6 @@ export const OverflowDropdown: Story = { created_at: "", updated_at: "", is_default: false, - permissions: {}, }, { id: "my-organization-6-id", @@ -132,7 +102,6 @@ export const OverflowDropdown: Story = { created_at: "", updated_at: "", is_default: false, - permissions: {}, }, { id: "my-organization-7-id", @@ -143,7 +112,6 @@ export const OverflowDropdown: Story = { created_at: "", updated_at: "", is_default: false, - permissions: {}, }, ], }, @@ -157,127 +125,53 @@ export const OverflowDropdown: Story = { export const NoPermissions: Story = { args: { - activeOrganization: { - ...MockOrganization, - permissions: MockNoPermissions, - }, + activeOrganization: MockOrganization, + orgPermissions: MockNoOrganizationPermissions, permissions: MockNoPermissions, }, }; export const AllPermissions: Story = { args: { - activeOrganization: { - ...MockOrganization, - permissions: { - editOrganization: true, - editMembers: true, - editGroups: true, - auditOrganization: true, - assignOrgRole: true, - viewProvisioners: true, - viewIdpSyncSettings: true, - }, - }, - organizations: [ - { - ...MockOrganization, - permissions: { - editOrganization: true, - editMembers: true, - editGroups: true, - auditOrganization: true, - assignOrgRole: true, - viewProvisioners: true, - viewIdpSyncSettings: true, - }, - }, - ], + activeOrganization: MockOrganization, + orgPermissions: MockOrganizationPermissions, + organizations: [MockOrganization], }, }; export const SelectedOrgAdmin: Story = { args: { - activeOrganization: { - ...MockOrganization, - permissions: { - editOrganization: true, - editMembers: true, - editGroups: true, - auditOrganization: true, - assignOrgRole: true, - }, - }, - organizations: [ - { - ...MockOrganization, - permissions: { - editOrganization: true, - editMembers: true, - editGroups: true, - auditOrganization: true, - assignOrgRole: true, - }, - }, - ], + activeOrganization: MockOrganization, + orgPermissions: MockOrganizationPermissions, + organizations: [MockOrganization], }, }; export const SelectedOrgAuditor: Story = { args: { - activeOrganization: { - ...MockOrganization, - permissions: { - editOrganization: false, - editMembers: false, - editGroups: false, - auditOrganization: true, - }, - }, + activeOrganization: MockOrganization, + orgPermissions: MockNoOrganizationPermissions, permissions: { ...MockPermissions, createOrganization: false, }, - organizations: [ - { - ...MockOrganization, - permissions: { - editOrganization: false, - editMembers: false, - editGroups: false, - auditOrganization: true, - }, - }, - ], + organizations: [MockOrganization], }, }; export const SelectedOrgUserAdmin: Story = { args: { - activeOrganization: { - ...MockOrganization, - permissions: { - editOrganization: false, - editMembers: true, - editGroups: true, - auditOrganization: false, - }, + activeOrganization: MockOrganization, + orgPermissions: { + ...MockNoOrganizationPermissions, + editMembers: true, + editGroups: true, }, permissions: { ...MockPermissions, createOrganization: false, }, - organizations: [ - { - ...MockOrganization, - permissions: { - editOrganization: false, - editMembers: true, - editGroups: true, - auditOrganization: false, - }, - }, - ], + organizations: [MockOrganization], }, }; diff --git a/site/src/modules/management/OrganizationSidebarView.tsx b/site/src/modules/management/OrganizationSidebarView.tsx index 8d913edf87df3..b86121f7cde93 100644 --- a/site/src/modules/management/OrganizationSidebarView.tsx +++ b/site/src/modules/management/OrganizationSidebarView.tsx @@ -7,31 +7,25 @@ import { CommandItem, CommandList, } from "components/Command/Command"; -import { Loader } from "components/Loader/Loader"; import { Popover, PopoverContent, PopoverTrigger, } from "components/Popover/Popover"; -import { - Sidebar as BaseSidebar, - SettingsSidebarNavItem, -} from "components/Sidebar/Sidebar"; +import { SettingsSidebarNavItem } from "components/Sidebar/Sidebar"; import type { Permissions } from "contexts/auth/permissions"; import { Check, ChevronDown, Plus } from "lucide-react"; -import { useDashboard } from "modules/dashboard/useDashboard"; import { type FC, useState } from "react"; import { useNavigate } from "react-router-dom"; - -export interface OrganizationWithPermissions extends Organization { - permissions: AuthorizationResponse; -} +import type { OrganizationPermissions } from "./organizationPermissions"; interface SidebarProps { /** The active org name, if any. Overrides activeSettings. */ - activeOrganization: OrganizationWithPermissions | undefined; + activeOrganization: Organization | undefined; + /** Permissions for the active organization */ + orgPermissions: OrganizationPermissions | undefined; /** Organizations and their permissions or undefined if still fetching. */ - organizations: OrganizationWithPermissions[] | undefined; + organizations: readonly Organization[]; /** Site-wide permissions. */ permissions: Permissions; } @@ -41,58 +35,17 @@ interface SidebarProps { */ export const OrganizationSidebarView: FC = ({ activeOrganization, + orgPermissions, organizations, permissions, }) => { - const { showOrganizations } = useDashboard(); - - return ( - - {showOrganizations && ( - - )} - - ); -}; - -function urlForSubpage(organizationName: string, subpage = ""): string { - return [`/organizations/${organizationName}`, subpage] - .filter(Boolean) - .join("/"); -} - -interface OrganizationsSettingsNavigationProps { - /** The active org name if an org is being viewed. */ - activeOrganization: OrganizationWithPermissions | undefined; - /** Organizations and their permissions or undefined if still fetching. */ - organizations: OrganizationWithPermissions[] | undefined; - /** Site-wide permissions. */ - permissions: Permissions; -} - -/** - * Displays navigation items for the active organization and a combobox to - * switch between organizations. - * - * If organizations or their permissions are still loading, show a loader. - */ -const OrganizationsSettingsNavigation: FC< - OrganizationsSettingsNavigationProps -> = ({ activeOrganization, organizations, permissions }) => { - // Wait for organizations and their permissions to load - if (!organizations || !activeOrganization) { - return ; - } - // Sort organizations to put active organization first - const sortedOrganizations = [ - activeOrganization, - ...organizations.filter((org) => org.id !== activeOrganization.id), - ]; + const sortedOrganizations = activeOrganization + ? [ + activeOrganization, + ...organizations.filter((org) => org.id !== activeOrganization.id), + ] + : organizations; const [isPopoverOpen, setIsPopoverOpen] = useState(false); const navigate = useNavigate(); @@ -125,7 +78,7 @@ const OrganizationsSettingsNavigation: FC< - {sortedOrganizations.length > 1 && ( + {sortedOrganizations.length > 1 ? (
{sortedOrganizations.map((organization) => ( {organization?.display_name || organization?.name} - {activeOrganization.name === organization.name && ( + {activeOrganization?.name === organization.name && ( ))}
+ ) : ( + + No more organizations + )} {permissions.createOrganization && ( <> @@ -181,58 +138,68 @@ const OrganizationsSettingsNavigation: FC<
- + {activeOrganization && orgPermissions && ( + + )} ); }; +function urlForSubpage(organizationName: string, subpage = ""): string { + return [`/organizations/${organizationName}`, subpage] + .filter(Boolean) + .join("/"); +} + interface OrganizationSettingsNavigationProps { - organization: OrganizationWithPermissions; + organization: Organization; + orgPermissions: AuthorizationResponse; } const OrganizationSettingsNavigation: FC< OrganizationSettingsNavigationProps -> = ({ organization }) => { +> = ({ organization, orgPermissions }) => { return ( <>
- {organization.permissions.editMembers && ( + {orgPermissions.viewMembers && ( Members )} - {organization.permissions.editGroups && ( + {orgPermissions.viewGroups && ( Groups )} - {organization.permissions.assignOrgRole && ( + {orgPermissions.assignOrgRole && ( Roles )} - {organization.permissions.viewProvisioners && ( + {orgPermissions.viewProvisioners && ( Provisioners )} - {organization.permissions.viewIdpSyncSettings && ( + {orgPermissions.viewIdpSyncSettings && ( IdP Sync )} - {organization.permissions.editOrganization && ( + {orgPermissions.editOrganization && ( diff --git a/site/src/modules/management/organizationPermissions.tsx b/site/src/modules/management/organizationPermissions.tsx new file mode 100644 index 0000000000000..d004af990b3ce --- /dev/null +++ b/site/src/modules/management/organizationPermissions.tsx @@ -0,0 +1,73 @@ +export type OrganizationPermissions = { + [k in OrganizationPermissionName]: boolean; +}; + +export type OrganizationPermissionName = keyof ReturnType< + typeof organizationPermissionChecks +>; + +export const organizationPermissionChecks = (organizationId: string) => ({ + viewMembers: { + object: { + resource_type: "organization_member", + organization_id: organizationId, + }, + action: "read", + }, + editMembers: { + object: { + resource_type: "organization_member", + organization_id: organizationId, + }, + action: "update", + }, + createGroup: { + object: { + resource_type: "group", + organization_id: organizationId, + }, + action: "create", + }, + viewGroups: { + object: { + resource_type: "group", + organization_id: organizationId, + }, + action: "read", + }, + editGroups: { + object: { + resource_type: "group", + organization_id: organizationId, + }, + action: "update", + }, + editOrganization: { + object: { + resource_type: "organization", + organization_id: organizationId, + }, + action: "update", + }, + assignOrgRole: { + object: { + resource_type: "assign_org_role", + organization_id: organizationId, + }, + action: "create", + }, + viewProvisioners: { + object: { + resource_type: "provisioner_daemon", + organization_id: organizationId, + }, + action: "read", + }, + viewIdpSyncSettings: { + object: { + resource_type: "idpsync_settings", + organization_id: organizationId, + }, + action: "read", + }, +}); diff --git a/site/src/pages/DeploymentSettingsPage/NotificationsPage/storybookUtils.ts b/site/src/pages/DeploymentSettingsPage/NotificationsPage/storybookUtils.ts index 4906a5ab54496..fc500efd847d6 100644 --- a/site/src/pages/DeploymentSettingsPage/NotificationsPage/storybookUtils.ts +++ b/site/src/pages/DeploymentSettingsPage/NotificationsPage/storybookUtils.ts @@ -13,7 +13,7 @@ import { withAuthProvider, withDashboardProvider, withGlobalSnackbar, - withManagementSettingsProvider, + withOrganizationSettingsProvider, } from "testHelpers/storybook"; import type { NotificationsPage } from "./NotificationsPage"; @@ -213,6 +213,6 @@ export const baseMeta = { withGlobalSnackbar, withAuthProvider, withDashboardProvider, - withManagementSettingsProvider, + withOrganizationSettingsProvider, ], } satisfies Meta; diff --git a/site/src/pages/GroupsPage/GroupsPage.tsx b/site/src/pages/GroupsPage/GroupsPage.tsx index 5e33e232227ef..b94063fd74e75 100644 --- a/site/src/pages/GroupsPage/GroupsPage.tsx +++ b/site/src/pages/GroupsPage/GroupsPage.tsx @@ -1,7 +1,7 @@ import GroupAdd from "@mui/icons-material/GroupAddOutlined"; import { getErrorMessage } from "api/errors"; import { groupsByOrganization } from "api/queries/groups"; -import { organizationPermissions } from "api/queries/organizations"; +import { organizationsPermissions } from "api/queries/organizations"; import { Button } from "components/Button/Button"; import { EmptyState } from "components/EmptyState/EmptyState"; import { displayError } from "components/GlobalSnackbar/utils"; @@ -16,6 +16,7 @@ import { Link as RouterLink } from "react-router-dom"; import { pageTitle } from "utils/page"; import { useGroupsSettings } from "./GroupsPageProvider"; import GroupsPageView from "./GroupsPageView"; +import { ErrorAlert } from "components/Alert/ErrorAlert"; export const GroupsPage: FC = () => { const { template_rbac: groupsEnabled } = useFeatureVisibility(); @@ -23,7 +24,11 @@ export const GroupsPage: FC = () => { const groupsQuery = useQuery( organization ? groupsByOrganization(organization.name) : { enabled: false }, ); - const permissionsQuery = useQuery(organizationPermissions(organization?.id)); + const permissionsQuery = useQuery( + organization + ? organizationsPermissions([organization.id]) + : { enabled: false }, + ); useEffect(() => { if (groupsQuery.error) { @@ -45,11 +50,15 @@ export const GroupsPage: FC = () => { return ; } - const permissions = permissionsQuery.data; - if (!permissions) { + if (permissionsQuery.isLoading) { return ; } + const permissions = permissionsQuery.data?.[organization.id]; + if (!permissions) { + return ; + } + return ( <> diff --git a/site/src/pages/GroupsPage/GroupsPageProvider.tsx b/site/src/pages/GroupsPage/GroupsPageProvider.tsx index 85ccd763be10a..3697705aebc4b 100644 --- a/site/src/pages/GroupsPage/GroupsPageProvider.tsx +++ b/site/src/pages/GroupsPage/GroupsPageProvider.tsx @@ -1,13 +1,6 @@ -import type { AuthorizationResponse, Organization } from "api/typesGenerated"; -import { useAuthenticated } from "contexts/auth/RequireAuth"; -import { RequirePermission } from "contexts/auth/RequirePermission"; +import type { Organization } from "api/typesGenerated"; import { useDashboard } from "modules/dashboard/useDashboard"; -import { - type FC, - type PropsWithChildren, - createContext, - useContext, -} from "react"; +import { type FC, createContext, useContext } from "react"; import { Navigate, Outlet, useParams } from "react-router-dom"; export const GroupsPageContext = createContext< diff --git a/site/src/pages/OrganizationSettingsPage/CustomRolesPage/CreateEditRolePage.tsx b/site/src/pages/OrganizationSettingsPage/CustomRolesPage/CreateEditRolePage.tsx index 9bb27679689fa..aa678f5a3e277 100644 --- a/site/src/pages/OrganizationSettingsPage/CustomRolesPage/CreateEditRolePage.tsx +++ b/site/src/pages/OrganizationSettingsPage/CustomRolesPage/CreateEditRolePage.tsx @@ -1,5 +1,4 @@ import { getErrorMessage } from "api/errors"; -import { organizationPermissions } from "api/queries/organizations"; import { createOrganizationRole, organizationRoles, @@ -24,9 +23,7 @@ export const CreateEditRolePage: FC = () => { organization: string; roleName: string; }; - const { organizations } = useOrganizationSettings(); - const organization = organizations?.find((o) => o.name === organizationName); - const permissionsQuery = useQuery(organizationPermissions(organization?.id)); + const { organizationPermissions } = useOrganizationSettings(); const createOrganizationRoleMutation = useMutation( createOrganizationRole(queryClient, organizationName), ); @@ -37,9 +34,8 @@ export const CreateEditRolePage: FC = () => { organizationRoles(organizationName), ); const role = roleData?.find((role) => role.name === roleName); - const permissions = permissionsQuery.data; - if (isLoading || !permissions) { + if (isLoading || !organizationPermissions) { return ; } @@ -80,7 +76,7 @@ export const CreateEditRolePage: FC = () => { : createOrganizationRoleMutation.isLoading } organizationName={organizationName} - canAssignOrgRole={permissions.assignOrgRole} + canAssignOrgRole={organizationPermissions.assignOrgRole} /> ); diff --git a/site/src/pages/OrganizationSettingsPage/CustomRolesPage/CustomRolesPage.tsx b/site/src/pages/OrganizationSettingsPage/CustomRolesPage/CustomRolesPage.tsx index 905e67ebd26e3..26fa8e45d8eb6 100644 --- a/site/src/pages/OrganizationSettingsPage/CustomRolesPage/CustomRolesPage.tsx +++ b/site/src/pages/OrganizationSettingsPage/CustomRolesPage/CustomRolesPage.tsx @@ -1,5 +1,4 @@ import { getErrorMessage } from "api/errors"; -import { organizationPermissions } from "api/queries/organizations"; import { deleteOrganizationRole, organizationRoles } from "api/queries/roles"; import type { Role } from "api/typesGenerated"; import { DeleteDialog } from "components/Dialogs/DeleteDialog/DeleteDialog"; @@ -22,13 +21,10 @@ export const CustomRolesPage: FC = () => { const { organization: organizationName } = useParams() as { organization: string; }; - const { organizations } = useOrganizationSettings(); - const organization = organizations?.find((o) => o.name === organizationName); - const permissionsQuery = useQuery(organizationPermissions(organization?.id)); - const deleteRoleMutation = useMutation( - deleteOrganizationRole(queryClient, organizationName), - ); + const { organizationPermissions } = useOrganizationSettings(); + const [roleToDelete, setRoleToDelete] = useState(); + const organizationRolesQuery = useQuery(organizationRoles(organizationName)); const builtInRoles = organizationRolesQuery.data?.filter( (role) => role.built_in, @@ -36,7 +32,10 @@ export const CustomRolesPage: FC = () => { const customRoles = organizationRolesQuery.data?.filter( (role) => !role.built_in, ); - const permissions = permissionsQuery.data; + + const deleteRoleMutation = useMutation( + deleteOrganizationRole(queryClient, organizationName), + ); useEffect(() => { if (organizationRolesQuery.error) { @@ -49,7 +48,7 @@ export const CustomRolesPage: FC = () => { } }, [organizationRolesQuery.error]); - if (!permissions) { + if (!organizationPermissions) { return ; } @@ -74,7 +73,7 @@ export const CustomRolesPage: FC = () => { builtInRoles={builtInRoles} customRoles={customRoles} onDeleteRole={setRoleToDelete} - canAssignOrgRole={permissions.assignOrgRole} + canAssignOrgRole={organizationPermissions.assignOrgRole} isCustomRolesEnabled={isCustomRolesEnabled} /> diff --git a/site/src/pages/OrganizationSettingsPage/DefaultOrganizationRedirect.tsx b/site/src/pages/OrganizationSettingsPage/DefaultOrganizationRedirect.tsx new file mode 100644 index 0000000000000..719ed357d3d45 --- /dev/null +++ b/site/src/pages/OrganizationSettingsPage/DefaultOrganizationRedirect.tsx @@ -0,0 +1,38 @@ +import type { FC } from "react"; +import { EmptyState } from "components/EmptyState/EmptyState"; +import { useOrganizationSettings } from "modules/management/OrganizationSettingsLayout"; +import { Navigate } from "react-router-dom"; +import type { OrganizationPermissions } from "modules/management/organizationPermissions"; + +/** + * Return true if the user can edit the organization settings or its members. + */ +const canEditOrganization = ( + permissions: OrganizationPermissions | undefined, +): permissions is OrganizationPermissions => { + return ( + permissions !== undefined && + (permissions.editOrganization || + permissions.editMembers || + permissions.editGroups) + ); +}; + +const DefaultOrganizationRedirect: FC = () => { + const { organizations, permissionsByOrganizationId } = + useOrganizationSettings(); + + // Redirect /organizations => /organizations/default-org, or if they cannot edit + // the default org, then the first org they can edit, if any. + // .find will stop at the first match found; make sure default + // organizations are placed first + const editableOrg = [...organizations] + .sort((a, b) => (b.is_default ? 1 : 0) - (a.is_default ? 1 : 0)) + .find((org) => canEditOrganization(permissionsByOrganizationId[org.id])); + if (editableOrg) { + return ; + } + return ; +}; + +export default DefaultOrganizationRedirect; diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationMembersPage.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationMembersPage.tsx index ac90365ea4d43..29ed27eb04942 100644 --- a/site/src/pages/OrganizationSettingsPage/OrganizationMembersPage.tsx +++ b/site/src/pages/OrganizationSettingsPage/OrganizationMembersPage.tsx @@ -4,7 +4,6 @@ import { groupsByUserIdInOrganization } from "api/queries/groups"; import { addOrganizationMember, organizationMembers, - organizationPermissions, removeOrganizationMember, updateOrganizationMemberRoles, } from "api/queries/organizations"; @@ -19,24 +18,24 @@ import { useOrganizationSettings } from "modules/management/OrganizationSettings import { type FC, useState } from "react"; import { Helmet } from "react-helmet-async"; import { useMutation, useQuery, useQueryClient } from "react-query"; -import { useParams } from "react-router-dom"; import { pageTitle } from "utils/page"; import { OrganizationMembersPageView } from "./OrganizationMembersPageView"; +import { useParams } from "react-router-dom"; const OrganizationMembersPage: FC = () => { const queryClient = useQueryClient(); + const { user: me } = useAuthenticated(); const { organization: organizationName } = useParams() as { organization: string; }; - const { user: me } = useAuthenticated(); + const { organization, organizationPermissions } = useOrganizationSettings(); + const membersQuery = useQuery(organizationMembers(organizationName)); + const organizationRolesQuery = useQuery(organizationRoles(organizationName)); const groupsByUserIdQuery = useQuery( groupsByUserIdInOrganization(organizationName), ); - const membersQuery = useQuery(organizationMembers(organizationName)); - const organizationRolesQuery = useQuery(organizationRoles(organizationName)); - const members = membersQuery.data?.map((member) => { const groups = groupsByUserIdQuery.data?.get(member.user_id) ?? []; return { ...member, groups }; @@ -52,15 +51,10 @@ const OrganizationMembersPage: FC = () => { updateOrganizationMemberRoles(queryClient, organizationName), ); - const { organizations } = useOrganizationSettings(); - const organization = organizations?.find((o) => o.name === organizationName); - const permissionsQuery = useQuery(organizationPermissions(organization?.id)); - const [memberToDelete, setMemberToDelete] = useState(); - const permissions = permissionsQuery.data; - if (!permissions) { + if (!organizationPermissions) { return ; } @@ -77,9 +71,11 @@ const OrganizationMembersPage: FC = () => { {helmet} = { decorators: [ withAuthProvider, withDashboardProvider, - withManagementSettingsProvider, + withOrganizationSettingsProvider, ], parameters: { showOrganizations: true, diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationSettingsPage.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationSettingsPage.tsx index 698f2ee75822f..5f29b71e63ad4 100644 --- a/site/src/pages/OrganizationSettingsPage/OrganizationSettingsPage.tsx +++ b/site/src/pages/OrganizationSettingsPage/OrganizationSettingsPage.tsx @@ -7,21 +7,18 @@ import { ErrorAlert } from "components/Alert/ErrorAlert"; import { EmptyState } from "components/EmptyState/EmptyState"; import { displaySuccess } from "components/GlobalSnackbar/utils"; import { Loader } from "components/Loader/Loader"; -import { useFeatureVisibility } from "modules/dashboard/useFeatureVisibility"; -import { canEditOrganization } from "modules/management/OrganizationSettingsLayout"; import { useOrganizationSettings } from "modules/management/OrganizationSettingsLayout"; import type { FC } from "react"; import { useMutation, useQuery, useQueryClient } from "react-query"; import { Navigate, useNavigate, useParams } from "react-router-dom"; import { OrganizationSettingsPageView } from "./OrganizationSettingsPageView"; -import { OrganizationSummaryPageView } from "./OrganizationSummaryPageView"; +import type { AuthorizationResponse } from "api/typesGenerated"; const OrganizationSettingsPage: FC = () => { const { organization: organizationName } = useParams() as { organization?: string; }; const { organizations } = useOrganizationSettings(); - const feats = useFeatureVisibility(); const navigate = useNavigate(); const queryClient = useQueryClient(); @@ -46,20 +43,6 @@ const OrganizationSettingsPage: FC = () => { return ; } - // Redirect /organizations => /organizations/default-org, or if they cannot edit - // the default org, then the first org they can edit, if any. - if (!organizationName) { - // .find will stop at the first match found; make sure default - // organizations are placed first - const editableOrg = [...organizations] - .sort((a, b) => (b.is_default ? 1 : 0) - (a.is_default ? 1 : 0)) - .find((org) => canEditOrganization(permissions[org.id])); - if (editableOrg) { - return ; - } - return ; - } - if (!organization) { return ; } @@ -69,11 +52,8 @@ const OrganizationSettingsPage: FC = () => { // summary page instead of the settings form. // Similarly, if the feature is not entitled then the user will not be able to // edit the organization. - if ( - !permissions[organization.id]?.editOrganization || - !feats.multiple_organizations - ) { - return ; + if (!permissions[organization.id]?.editOrganization) { + return ; } const error = diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationSummaryPageView.stories.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationSummaryPageView.stories.tsx deleted file mode 100644 index 92567ad99fac4..0000000000000 --- a/site/src/pages/OrganizationSettingsPage/OrganizationSummaryPageView.stories.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react"; -import { - MockDefaultOrganization, - MockOrganization, -} from "testHelpers/entities"; -import { OrganizationSummaryPageView } from "./OrganizationSummaryPageView"; - -const meta: Meta = { - title: "pages/OrganizationSummaryPageView", - component: OrganizationSummaryPageView, - args: { - organization: MockOrganization, - }, -}; - -export default meta; -type Story = StoryObj; - -export const DefaultOrg: Story = { - args: { - organization: MockDefaultOrganization, - }, -}; diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationSummaryPageView.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationSummaryPageView.tsx deleted file mode 100644 index c12b3c13a416c..0000000000000 --- a/site/src/pages/OrganizationSettingsPage/OrganizationSummaryPageView.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import type { Organization } from "api/typesGenerated"; -import { Avatar } from "components/Avatar/Avatar"; -import { - PageHeader, - PageHeaderSubtitle, - PageHeaderTitle, -} from "components/PageHeader/PageHeader"; -import { Stack } from "components/Stack/Stack"; -import type { FC } from "react"; - -interface OrganizationSummaryPageViewProps { - organization: Organization; -} - -export const OrganizationSummaryPageView: FC< - OrganizationSummaryPageViewProps -> = ({ organization }) => { - return ( -
- - - - -
- - {organization.display_name || organization.name} - - {organization.description && ( - - {organization.description} - - )} -
-
-
- You are a member of this organization. -
- ); -}; diff --git a/site/src/router.tsx b/site/src/router.tsx index acaf417cecbcd..c2a460844af78 100644 --- a/site/src/router.tsx +++ b/site/src/router.tsx @@ -228,6 +228,10 @@ const AddNewLicensePage = lazy( "./pages/DeploymentSettingsPage/LicensesSettingsPage/AddNewLicensePage" ), ); +const DefaultOrganizationRedirect = lazy( + () => import("./pages/OrganizationSettingsPage/DefaultOrganizationRedirect"), +); + const CreateOrganizationPage = lazy( () => import("./pages/OrganizationSettingsPage/CreateOrganizationPage"), ); @@ -412,7 +416,7 @@ export const router = createBrowserRouter( } /> {/* General settings for the default org can omit the organization name */} - } /> + } /> }> } /> diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index c522457a63c1d..d1ef4f124a989 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -8,6 +8,7 @@ import type * as TypesGen from "api/typesGenerated"; import type { Permissions } from "contexts/auth/permissions"; import type { ProxyLatencyReport } from "contexts/useProxyLatency"; import range from "lodash/range"; +import type { OrganizationPermissions } from "modules/management/organizationPermissions"; import type { FileTree } from "utils/filetree"; import type { TemplateVersionFiles } from "utils/templateVersion"; @@ -2823,6 +2824,30 @@ export const MockPermissions: Permissions = { viewOrganizationIDPSyncSettings: true, }; +export const MockOrganizationPermissions: OrganizationPermissions = { + viewMembers: true, + editMembers: true, + createGroup: true, + viewGroups: true, + editGroups: true, + editOrganization: true, + assignOrgRole: true, + viewProvisioners: true, + viewIdpSyncSettings: true, +}; + +export const MockNoOrganizationPermissions: OrganizationPermissions = { + viewMembers: false, + editMembers: false, + createGroup: false, + viewGroups: false, + editGroups: false, + editOrganization: false, + assignOrgRole: false, + viewProvisioners: false, + viewIdpSyncSettings: false, +}; + export const MockNoPermissions: Permissions = { createTemplates: false, createUser: false, diff --git a/site/src/testHelpers/storybook.tsx b/site/src/testHelpers/storybook.tsx index f1bdc8fadd0f0..0b73445ca2a44 100644 --- a/site/src/testHelpers/storybook.tsx +++ b/site/src/testHelpers/storybook.tsx @@ -17,6 +17,8 @@ import { MockDefaultOrganization, MockDeploymentConfig, MockEntitlements, + MockOrganizationPermissions, + MockPermissions, } from "./entities"; export const withDashboardProvider = ( @@ -153,12 +155,16 @@ export const withGlobalSnackbar = (Story: FC) => ( ); -export const withManagementSettingsProvider = (Story: FC) => { +export const withOrganizationSettingsProvider = (Story: FC) => { return ( Date: Wed, 5 Feb 2025 23:11:56 +0000 Subject: [PATCH 02/21] fix even more-er permissions --- site/src/modules/dashboard/Navbar/Navbar.tsx | 7 ++++--- site/src/modules/management/OrganizationSettingsLayout.tsx | 5 +++-- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/site/src/modules/dashboard/Navbar/Navbar.tsx b/site/src/modules/dashboard/Navbar/Navbar.tsx index fa249f3a7f004..684b940e2ffa1 100644 --- a/site/src/modules/dashboard/Navbar/Navbar.tsx +++ b/site/src/modules/dashboard/Navbar/Navbar.tsx @@ -16,10 +16,11 @@ export const Navbar: FC = () => { const { user: me, permissions, signOut } = useAuthenticated(); const featureVisibility = useFeatureVisibility(); const canViewAuditLog = - featureVisibility.audit_log && Boolean(permissions.viewAnyAuditLog); - const canViewDeployment = Boolean(permissions.viewDeploymentValues); + featureVisibility.audit_log && permissions.viewAnyAuditLog; + const canViewDeployment = permissions.viewDeploymentValues; const canViewOrganizations = - Boolean(permissions.editAnyOrganization) && showOrganizations; + (permissions.viewDeploymentValues || permissions.editAnyOrganization) && + showOrganizations; const proxyContextValue = useProxy(); const canViewHealth = canViewDeployment; diff --git a/site/src/modules/management/OrganizationSettingsLayout.tsx b/site/src/modules/management/OrganizationSettingsLayout.tsx index f7f7da3e674c9..da7c2cde2ee33 100644 --- a/site/src/modules/management/OrganizationSettingsLayout.tsx +++ b/site/src/modules/management/OrganizationSettingsLayout.tsx @@ -65,7 +65,8 @@ const OrganizationSettingsLayout: FC = () => { organization?: string; }; - const canViewOrganizationSettingsPage = permissions.editAnyOrganization; + const canViewOrganizationSettings = + permissions.viewDeploymentValues || permissions.editAnyOrganization; const organization = orgName ? organizations.find((org) => org.name === orgName) @@ -98,7 +99,7 @@ const OrganizationSettingsLayout: FC = () => { } return ( - + Date: Fri, 7 Feb 2025 23:04:16 +0000 Subject: [PATCH 03/21] oh yeah --- site/src/api/queries/organizations.ts | 12 ++ site/src/contexts/auth/AuthProvider.tsx | 2 + site/src/contexts/auth/permissions.tsx | 8 - .../modules/dashboard/DashboardProvider.tsx | 22 +- site/src/modules/dashboard/Navbar/Navbar.tsx | 6 +- .../management/OrganizationSettingsLayout.tsx | 28 +-- .../management/organizationPermissions.tsx | 198 ++++++++++++++---- .../CustomRolesPage/CreateEditRolePage.tsx | 2 +- .../CustomRolesPage/CustomRolesPage.tsx | 2 +- .../DefaultOrganizationRedirect.tsx | 16 +- .../WorkspacePage/WorkspacePage.test.tsx | 1 + site/src/testHelpers/entities.ts | 10 +- site/src/testHelpers/storybook.tsx | 5 +- 13 files changed, 207 insertions(+), 105 deletions(-) diff --git a/site/src/api/queries/organizations.ts b/site/src/api/queries/organizations.ts index b575a3125dec3..45e44d8d18ab0 100644 --- a/site/src/api/queries/organizations.ts +++ b/site/src/api/queries/organizations.ts @@ -11,6 +11,8 @@ import { organizationPermissionChecks, type OrganizationPermissions, type OrganizationPermissionName, + anyOrganizationPermissionChecks, + type AnyOrganizationPermissions, } from "modules/management/organizationPermissions"; export const createOrganization = (queryClient: QueryClient) => { @@ -251,6 +253,16 @@ export const organizationsPermissions = ( }; }; +export const anyOrganizationPermissions = () => { + return { + queryKey: ["authorization", "anyOrganization"], + queryFn: () => + API.checkAuthorization({ + checks: anyOrganizationPermissionChecks, + }) as Promise, + }; +}; + export const getOrganizationIdpSyncClaimFieldValuesKey = ( organization: string, field: string, diff --git a/site/src/contexts/auth/AuthProvider.tsx b/site/src/contexts/auth/AuthProvider.tsx index ad475bddcbfb7..fd0820e8d91c9 100644 --- a/site/src/contexts/auth/AuthProvider.tsx +++ b/site/src/contexts/auth/AuthProvider.tsx @@ -101,6 +101,8 @@ export const AuthProvider: FC = ({ children }) => { [updateProfileMutation], ); + console.log(permissionsQuery.data); + return ( ( @@ -33,12 +38,16 @@ export const DashboardProvider: FC = ({ children }) => { const experimentsQuery = useQuery(experiments(metadata.experiments)); const appearanceQuery = useQuery(appearance(metadata.appearance)); const organizationsQuery = useQuery(organizations()); + const anyOrganizationPermissionsQuery = useQuery( + anyOrganizationPermissions(), + ); const error = entitlementsQuery.error || appearanceQuery.error || experimentsQuery.error || - organizationsQuery.error; + organizationsQuery.error || + anyOrganizationPermissionsQuery.error; if (error) { return ; @@ -48,7 +57,8 @@ export const DashboardProvider: FC = ({ children }) => { !entitlementsQuery.data || !appearanceQuery.data || !experimentsQuery.data || - !organizationsQuery.data; + !organizationsQuery.data || + !anyOrganizationPermissionsQuery.data; if (isLoading) { return ; @@ -58,6 +68,7 @@ export const DashboardProvider: FC = ({ children }) => { const organizationsEnabled = selectFeatureVisibility( entitlementsQuery.data, ).multiple_organizations; + const showOrganizations = hasMultipleOrganizations || organizationsEnabled; return ( = ({ children }) => { experiments: experimentsQuery.data, appearance: appearanceQuery.data, organizations: organizationsQuery.data, - showOrganizations: hasMultipleOrganizations || organizationsEnabled, + showOrganizations, + canViewOrganizationSettings: + showOrganizations && + canViewAnyOrganization(anyOrganizationPermissionsQuery.data), }} > {children} diff --git a/site/src/modules/dashboard/Navbar/Navbar.tsx b/site/src/modules/dashboard/Navbar/Navbar.tsx index 684b940e2ffa1..f80887e1f1aec 100644 --- a/site/src/modules/dashboard/Navbar/Navbar.tsx +++ b/site/src/modules/dashboard/Navbar/Navbar.tsx @@ -12,15 +12,13 @@ export const Navbar: FC = () => { const { metadata } = useEmbeddedMetadata(); const buildInfoQuery = useQuery(buildInfo(metadata["build-info"])); - const { appearance, showOrganizations } = useDashboard(); + const { appearance, canViewOrganizationSettings } = useDashboard(); const { user: me, permissions, signOut } = useAuthenticated(); const featureVisibility = useFeatureVisibility(); const canViewAuditLog = featureVisibility.audit_log && permissions.viewAnyAuditLog; const canViewDeployment = permissions.viewDeploymentValues; - const canViewOrganizations = - (permissions.viewDeploymentValues || permissions.editAnyOrganization) && - showOrganizations; + const canViewOrganizations = canViewOrganizationSettings; const proxyContextValue = useProxy(); const canViewHealth = canViewDeployment; diff --git a/site/src/modules/management/OrganizationSettingsLayout.tsx b/site/src/modules/management/OrganizationSettingsLayout.tsx index da7c2cde2ee33..6a277c880ec2f 100644 --- a/site/src/modules/management/OrganizationSettingsLayout.tsx +++ b/site/src/modules/management/OrganizationSettingsLayout.tsx @@ -17,7 +17,10 @@ import NotFoundPage from "pages/404Page/404Page"; import { type FC, Suspense, createContext, useContext } from "react"; import { useQuery } from "react-query"; import { Outlet, useParams } from "react-router-dom"; -import type { OrganizationPermissions } from "./organizationPermissions"; +import { + canViewOrganization, + type OrganizationPermissions, +} from "./organizationPermissions"; export const OrganizationSettingsContext = createContext< OrganizationSettingsValue | undefined @@ -41,33 +44,12 @@ export const useOrganizationSettings = (): OrganizationSettingsValue => { return context; }; -/** - * Checks if the user can view or edit members or groups for the organization - * that produced the given OrganizationPermissions. - */ -const canViewOrganization = ( - permissions: OrganizationPermissions | undefined, -): permissions is OrganizationPermissions => { - return ( - permissions !== undefined && - (permissions.editOrganization || - permissions.editMembers || - permissions.viewMembers || - permissions.editGroups || - permissions.viewGroups) - ); -}; - const OrganizationSettingsLayout: FC = () => { - const { permissions } = useAuthenticated(); - const { organizations } = useDashboard(); + const { organizations, canViewOrganizationSettings } = useDashboard(); const { organization: orgName } = useParams() as { organization?: string; }; - const canViewOrganizationSettings = - permissions.viewDeploymentValues || permissions.editAnyOrganization; - const organization = orgName ? organizations.find((org) => org.name === orgName) : undefined; diff --git a/site/src/modules/management/organizationPermissions.tsx b/site/src/modules/management/organizationPermissions.tsx index d004af990b3ce..cc5860f470ab1 100644 --- a/site/src/modules/management/organizationPermissions.tsx +++ b/site/src/modules/management/organizationPermissions.tsx @@ -1,3 +1,5 @@ +import type { AuthorizationCheck } from "api/typesGenerated"; + export type OrganizationPermissions = { [k in OrganizationPermissionName]: boolean; }; @@ -6,68 +8,178 @@ export type OrganizationPermissionName = keyof ReturnType< typeof organizationPermissionChecks >; -export const organizationPermissionChecks = (organizationId: string) => ({ - viewMembers: { - object: { - resource_type: "organization_member", - organization_id: organizationId, +export const organizationPermissionChecks = (organizationId: string) => + ({ + viewMembers: { + object: { + resource_type: "organization_member", + organization_id: organizationId, + }, + action: "read", }, - action: "read", - }, - editMembers: { - object: { - resource_type: "organization_member", - organization_id: organizationId, + editMembers: { + object: { + resource_type: "organization_member", + organization_id: organizationId, + }, + action: "update", }, - action: "update", - }, - createGroup: { - object: { - resource_type: "group", - organization_id: organizationId, + createGroup: { + object: { + resource_type: "group", + organization_id: organizationId, + }, + action: "create", }, - action: "create", - }, - viewGroups: { + viewGroups: { + object: { + resource_type: "group", + organization_id: organizationId, + }, + action: "read", + }, + editGroups: { + object: { + resource_type: "group", + organization_id: organizationId, + }, + action: "update", + }, + editOrganization: { + object: { + resource_type: "organization", + organization_id: organizationId, + }, + action: "update", + }, + assignOrgRoles: { + object: { + resource_type: "assign_org_role", + organization_id: organizationId, + }, + action: "assign", + }, + viewOrgRoles: { + object: { + resource_type: "assign_org_role", + organization_id: organizationId, + }, + action: "read", + }, + createOrgRoles: { + object: { + resource_type: "assign_org_role", + organization_id: organizationId, + }, + action: "create", + }, + viewProvisioners: { + object: { + resource_type: "provisioner_daemon", + organization_id: organizationId, + }, + action: "read", + }, + viewIdpSyncSettings: { + object: { + resource_type: "idpsync_settings", + organization_id: organizationId, + }, + action: "read", + }, + }) as const satisfies Record; + +/** + * Checks if the user can view or edit members or groups for the organization + * that produced the given OrganizationPermissions. + */ +export const canViewOrganization = ( + permissions: OrganizationPermissions | undefined, +): permissions is OrganizationPermissions => { + return ( + permissions !== undefined && + (permissions.viewMembers || + permissions.viewGroups || + permissions.viewOrgRoles || + permissions.viewProvisioners || + permissions.viewIdpSyncSettings) + ); +}; + +/** + * Return true if the user can edit the organization settings or its members. + */ +export const canEditOrganization = ( + permissions: OrganizationPermissions | undefined, +): permissions is OrganizationPermissions => { + return ( + permissions !== undefined && + (permissions.editMembers || + permissions.editGroups || + permissions.editOrganization || + permissions.assignOrgRoles || + permissions.createOrgRoles) + ); +}; + +export type AnyOrganizationPermissions = { + [k in AnyOrganizationPermissionName]: boolean; +}; + +export type AnyOrganizationPermissionName = + keyof typeof anyOrganizationPermissionChecks; + +export const anyOrganizationPermissionChecks = { + viewAnyMembers: { object: { - resource_type: "group", - organization_id: organizationId, + resource_type: "organization_member", + any_org: true, }, action: "read", }, - editGroups: { + editAnyGroups: { object: { resource_type: "group", - organization_id: organizationId, - }, - action: "update", - }, - editOrganization: { - object: { - resource_type: "organization", - organization_id: organizationId, + any_org: true, }, action: "update", }, - assignOrgRole: { + assignAnyRoles: { object: { resource_type: "assign_org_role", - organization_id: organizationId, + any_org: true, }, - action: "create", + action: "assign", }, - viewProvisioners: { + editAnyIdpSyncSettings: { object: { - resource_type: "provisioner_daemon", - organization_id: organizationId, + resource_type: "idpsync_settings", + any_org: true, }, - action: "read", + action: "update", }, - viewIdpSyncSettings: { + editAnySettings: { object: { - resource_type: "idpsync_settings", - organization_id: organizationId, + resource_type: "organization", + any_org: true, }, - action: "read", + action: "update", }, -}); +} as const satisfies Record; + +/** + * Checks if the user can view or edit members or groups for the organization + * that produced the given OrganizationPermissions. + */ +export const canViewAnyOrganization = ( + permissions: AnyOrganizationPermissions | undefined, +): permissions is AnyOrganizationPermissions => { + return ( + permissions !== undefined && + (permissions.viewAnyMembers || + permissions.editAnyGroups || + permissions.assignAnyRoles || + permissions.editAnyIdpSyncSettings || + permissions.editAnySettings) + ); +}; diff --git a/site/src/pages/OrganizationSettingsPage/CustomRolesPage/CreateEditRolePage.tsx b/site/src/pages/OrganizationSettingsPage/CustomRolesPage/CreateEditRolePage.tsx index aa678f5a3e277..d073ed698ec66 100644 --- a/site/src/pages/OrganizationSettingsPage/CustomRolesPage/CreateEditRolePage.tsx +++ b/site/src/pages/OrganizationSettingsPage/CustomRolesPage/CreateEditRolePage.tsx @@ -76,7 +76,7 @@ export const CreateEditRolePage: FC = () => { : createOrganizationRoleMutation.isLoading } organizationName={organizationName} - canAssignOrgRole={organizationPermissions.assignOrgRole} + canAssignOrgRole={organizationPermissions.assignOrgRoles} /> ); diff --git a/site/src/pages/OrganizationSettingsPage/CustomRolesPage/CustomRolesPage.tsx b/site/src/pages/OrganizationSettingsPage/CustomRolesPage/CustomRolesPage.tsx index 26fa8e45d8eb6..096675adb1da5 100644 --- a/site/src/pages/OrganizationSettingsPage/CustomRolesPage/CustomRolesPage.tsx +++ b/site/src/pages/OrganizationSettingsPage/CustomRolesPage/CustomRolesPage.tsx @@ -73,7 +73,7 @@ export const CustomRolesPage: FC = () => { builtInRoles={builtInRoles} customRoles={customRoles} onDeleteRole={setRoleToDelete} - canAssignOrgRole={organizationPermissions.assignOrgRole} + canAssignOrgRole={organizationPermissions.assignOrgRoles} isCustomRolesEnabled={isCustomRolesEnabled} /> diff --git a/site/src/pages/OrganizationSettingsPage/DefaultOrganizationRedirect.tsx b/site/src/pages/OrganizationSettingsPage/DefaultOrganizationRedirect.tsx index 719ed357d3d45..f071b2c8bb086 100644 --- a/site/src/pages/OrganizationSettingsPage/DefaultOrganizationRedirect.tsx +++ b/site/src/pages/OrganizationSettingsPage/DefaultOrganizationRedirect.tsx @@ -2,21 +2,7 @@ import type { FC } from "react"; import { EmptyState } from "components/EmptyState/EmptyState"; import { useOrganizationSettings } from "modules/management/OrganizationSettingsLayout"; import { Navigate } from "react-router-dom"; -import type { OrganizationPermissions } from "modules/management/organizationPermissions"; - -/** - * Return true if the user can edit the organization settings or its members. - */ -const canEditOrganization = ( - permissions: OrganizationPermissions | undefined, -): permissions is OrganizationPermissions => { - return ( - permissions !== undefined && - (permissions.editOrganization || - permissions.editMembers || - permissions.editGroups) - ); -}; +import { canEditOrganization } from "modules/management/organizationPermissions"; const DefaultOrganizationRedirect: FC = () => { const { organizations, permissionsByOrganizationId } = diff --git a/site/src/pages/WorkspacePage/WorkspacePage.test.tsx b/site/src/pages/WorkspacePage/WorkspacePage.test.tsx index 1c644f981d7a6..50f47a4721320 100644 --- a/site/src/pages/WorkspacePage/WorkspacePage.test.tsx +++ b/site/src/pages/WorkspacePage/WorkspacePage.test.tsx @@ -565,6 +565,7 @@ describe("WorkspacePage", () => { experiments: [], organizations: [MockOrganization], showOrganizations: true, + canViewOrganizationSettings: true, }} > {children} diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index b9dea64d9ee75..a8df56dc5affd 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -2823,7 +2823,6 @@ export const MockPermissions: Permissions = { readWorkspaceProxies: true, editWorkspaceProxies: true, createOrganization: true, - editAnyOrganization: true, viewAnyGroup: true, createGroup: true, viewAllLicenses: true, @@ -2838,7 +2837,9 @@ export const MockOrganizationPermissions: OrganizationPermissions = { viewGroups: true, editGroups: true, editOrganization: true, - assignOrgRole: true, + viewOrgRoles: true, + createOrgRoles: true, + assignOrgRoles: true, viewProvisioners: true, viewIdpSyncSettings: true, }; @@ -2850,7 +2851,9 @@ export const MockNoOrganizationPermissions: OrganizationPermissions = { viewGroups: false, editGroups: false, editOrganization: false, - assignOrgRole: false, + viewOrgRoles: false, + createOrgRoles: false, + assignOrgRoles: false, viewProvisioners: false, viewIdpSyncSettings: false, }; @@ -2871,7 +2874,6 @@ export const MockNoPermissions: Permissions = { readWorkspaceProxies: false, editWorkspaceProxies: false, createOrganization: false, - editAnyOrganization: false, viewAnyGroup: false, createGroup: false, viewAllLicenses: false, diff --git a/site/src/testHelpers/storybook.tsx b/site/src/testHelpers/storybook.tsx index 0b73445ca2a44..a483696a8cf46 100644 --- a/site/src/testHelpers/storybook.tsx +++ b/site/src/testHelpers/storybook.tsx @@ -18,7 +18,6 @@ import { MockDeploymentConfig, MockEntitlements, MockOrganizationPermissions, - MockPermissions, } from "./entities"; export const withDashboardProvider = ( @@ -30,6 +29,7 @@ export const withDashboardProvider = ( experiments = [], showOrganizations = false, organizations = [MockDefaultOrganization], + canViewOrganizationSettings = false, } = parameters; const entitlements: Entitlements = { @@ -50,9 +50,10 @@ export const withDashboardProvider = ( value={{ entitlements, experiments, + appearance: MockAppearanceConfig, organizations, showOrganizations, - appearance: MockAppearanceConfig, + canViewOrganizationSettings, }} > From 379bacd1556803a71f286121e16b3b7d63612812 Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Fri, 7 Feb 2025 23:05:33 +0000 Subject: [PATCH 04/21] oh yeah --- .../management/OrganizationSettingsLayout.tsx | 9 +++++++-- .../management/OrganizationSidebarView.tsx | 8 +++++--- .../DefaultOrganizationRedirect.tsx | 20 ++++++++++++------- site/src/testHelpers/storybook.tsx | 2 +- 4 files changed, 26 insertions(+), 13 deletions(-) diff --git a/site/src/modules/management/OrganizationSettingsLayout.tsx b/site/src/modules/management/OrganizationSettingsLayout.tsx index 6a277c880ec2f..2c310be2a7d6d 100644 --- a/site/src/modules/management/OrganizationSettingsLayout.tsx +++ b/site/src/modules/management/OrganizationSettingsLayout.tsx @@ -28,7 +28,10 @@ export const OrganizationSettingsContext = createContext< type OrganizationSettingsValue = Readonly<{ organizations: readonly Organization[]; - permissionsByOrganizationId: Record; + organizationPermissionsByOrganizationId: Record< + string, + OrganizationPermissions + >; organization?: Organization; organizationPermissions?: OrganizationPermissions; }>; @@ -80,12 +83,14 @@ const OrganizationSettingsLayout: FC = () => { return ; } + console.log(orgPermissionsQuery.data); + return ( = ({ ))}
) : ( - - No more organizations - + !permissions.createOrganization && ( + + No more organizations + + ) )} {permissions.createOrganization && ( <> diff --git a/site/src/pages/OrganizationSettingsPage/DefaultOrganizationRedirect.tsx b/site/src/pages/OrganizationSettingsPage/DefaultOrganizationRedirect.tsx index f071b2c8bb086..d65a105b991e1 100644 --- a/site/src/pages/OrganizationSettingsPage/DefaultOrganizationRedirect.tsx +++ b/site/src/pages/OrganizationSettingsPage/DefaultOrganizationRedirect.tsx @@ -5,19 +5,25 @@ import { Navigate } from "react-router-dom"; import { canEditOrganization } from "modules/management/organizationPermissions"; const DefaultOrganizationRedirect: FC = () => { - const { organizations, permissionsByOrganizationId } = - useOrganizationSettings(); + const { + organizations, + organizationPermissionsByOrganizationId: organizationPermissions, + } = useOrganizationSettings(); - // Redirect /organizations => /organizations/default-org, or if they cannot edit - // the default org, then the first org they can edit, if any. - // .find will stop at the first match found; make sure default - // organizations are placed first + // Redirect /organizations => /organizations/some-organization-name + // If they can edit the default org, we should redirect to the default. + // If they cannot edit the default, we should redirect to the first org that + // they can edit. const editableOrg = [...organizations] .sort((a, b) => (b.is_default ? 1 : 0) - (a.is_default ? 1 : 0)) - .find((org) => canEditOrganization(permissionsByOrganizationId[org.id])); + .find((org) => canEditOrganization(organizationPermissions[org.id])); if (editableOrg) { return ; } + // If they cannot edit any org, just redirect to an org they can read. + if (organizations.length > 0) { + return ; + } return ; }; diff --git a/site/src/testHelpers/storybook.tsx b/site/src/testHelpers/storybook.tsx index a483696a8cf46..2b81bf16cd40f 100644 --- a/site/src/testHelpers/storybook.tsx +++ b/site/src/testHelpers/storybook.tsx @@ -161,7 +161,7 @@ export const withOrganizationSettingsProvider = (Story: FC) => { Date: Fri, 7 Feb 2025 23:06:21 +0000 Subject: [PATCH 05/21] =?UTF-8?q?=F0=9F=94=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- site/src/api/queries/organizations.ts | 10 +++++----- site/src/contexts/auth/AuthProvider.tsx | 2 -- site/src/modules/dashboard/DashboardProvider.tsx | 2 +- .../modules/management/OrganizationSettingsLayout.tsx | 4 +--- site/src/modules/management/OrganizationSidebar.tsx | 2 +- site/src/pages/GroupsPage/GroupsPage.tsx | 2 +- .../DefaultOrganizationRedirect.tsx | 4 ++-- .../OrganizationMembersPage.tsx | 2 +- .../OrganizationSettingsPage.tsx | 2 +- 9 files changed, 13 insertions(+), 17 deletions(-) diff --git a/site/src/api/queries/organizations.ts b/site/src/api/queries/organizations.ts index 45e44d8d18ab0..13914006c370a 100644 --- a/site/src/api/queries/organizations.ts +++ b/site/src/api/queries/organizations.ts @@ -5,15 +5,15 @@ import type { RoleSyncSettings, UpdateOrganizationRequest, } from "api/typesGenerated"; -import type { QueryClient } from "react-query"; -import { meKey } from "./users"; import { - organizationPermissionChecks, - type OrganizationPermissions, + type AnyOrganizationPermissions, type OrganizationPermissionName, + type OrganizationPermissions, anyOrganizationPermissionChecks, - type AnyOrganizationPermissions, + organizationPermissionChecks, } from "modules/management/organizationPermissions"; +import type { QueryClient } from "react-query"; +import { meKey } from "./users"; export const createOrganization = (queryClient: QueryClient) => { return { diff --git a/site/src/contexts/auth/AuthProvider.tsx b/site/src/contexts/auth/AuthProvider.tsx index fd0820e8d91c9..ad475bddcbfb7 100644 --- a/site/src/contexts/auth/AuthProvider.tsx +++ b/site/src/contexts/auth/AuthProvider.tsx @@ -101,8 +101,6 @@ export const AuthProvider: FC = ({ children }) => { [updateProfileMutation], ); - console.log(permissionsQuery.data); - return ( { return ; } - console.log(orgPermissionsQuery.data); - return ( { const { template_rbac: groupsEnabled } = useFeatureVisibility(); diff --git a/site/src/pages/OrganizationSettingsPage/DefaultOrganizationRedirect.tsx b/site/src/pages/OrganizationSettingsPage/DefaultOrganizationRedirect.tsx index d65a105b991e1..d69fc50aa1491 100644 --- a/site/src/pages/OrganizationSettingsPage/DefaultOrganizationRedirect.tsx +++ b/site/src/pages/OrganizationSettingsPage/DefaultOrganizationRedirect.tsx @@ -1,8 +1,8 @@ -import type { FC } from "react"; import { EmptyState } from "components/EmptyState/EmptyState"; import { useOrganizationSettings } from "modules/management/OrganizationSettingsLayout"; -import { Navigate } from "react-router-dom"; import { canEditOrganization } from "modules/management/organizationPermissions"; +import type { FC } from "react"; +import { Navigate } from "react-router-dom"; const DefaultOrganizationRedirect: FC = () => { const { diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationMembersPage.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationMembersPage.tsx index 29ed27eb04942..7d3aa534ca4a0 100644 --- a/site/src/pages/OrganizationSettingsPage/OrganizationMembersPage.tsx +++ b/site/src/pages/OrganizationSettingsPage/OrganizationMembersPage.tsx @@ -18,9 +18,9 @@ import { useOrganizationSettings } from "modules/management/OrganizationSettings import { type FC, useState } from "react"; import { Helmet } from "react-helmet-async"; import { useMutation, useQuery, useQueryClient } from "react-query"; +import { useParams } from "react-router-dom"; import { pageTitle } from "utils/page"; import { OrganizationMembersPageView } from "./OrganizationMembersPageView"; -import { useParams } from "react-router-dom"; const OrganizationMembersPage: FC = () => { const queryClient = useQueryClient(); diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationSettingsPage.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationSettingsPage.tsx index 5f29b71e63ad4..06f286f0298ed 100644 --- a/site/src/pages/OrganizationSettingsPage/OrganizationSettingsPage.tsx +++ b/site/src/pages/OrganizationSettingsPage/OrganizationSettingsPage.tsx @@ -3,6 +3,7 @@ import { organizationsPermissions, updateOrganization, } from "api/queries/organizations"; +import type { AuthorizationResponse } from "api/typesGenerated"; import { ErrorAlert } from "components/Alert/ErrorAlert"; import { EmptyState } from "components/EmptyState/EmptyState"; import { displaySuccess } from "components/GlobalSnackbar/utils"; @@ -12,7 +13,6 @@ import type { FC } from "react"; import { useMutation, useQuery, useQueryClient } from "react-query"; import { Navigate, useNavigate, useParams } from "react-router-dom"; import { OrganizationSettingsPageView } from "./OrganizationSettingsPageView"; -import type { AuthorizationResponse } from "api/typesGenerated"; const OrganizationSettingsPage: FC = () => { const { organization: organizationName } = useParams() as { From b841aacae17c8c4f8a2bf53d3b300b7d74637e9d Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Sat, 8 Feb 2025 00:12:14 +0000 Subject: [PATCH 06/21] yes --- site/src/contexts/auth/RequirePermission.tsx | 3 +- .../management/OrganizationSettingsLayout.tsx | 98 +++++++++---------- .../management/OrganizationSidebarView.tsx | 26 +++-- .../management/organizationPermissions.tsx | 18 +++- .../OrganizationMembersPage.tsx | 7 +- ...test.tsx => OrganizationRedirect.test.tsx} | 58 ++++++----- ...nRedirect.tsx => OrganizationRedirect.tsx} | 4 +- .../OrganizationSettingsPage.tsx | 32 +----- .../OrganizationSettingsPageView.tsx | 1 - site/src/router.tsx | 6 +- site/src/testHelpers/entities.ts | 6 +- 11 files changed, 131 insertions(+), 128 deletions(-) rename site/src/pages/OrganizationSettingsPage/{OrganizationSettingsPage.test.tsx => OrganizationRedirect.test.tsx} (58%) rename site/src/pages/OrganizationSettingsPage/{DefaultOrganizationRedirect.tsx => OrganizationRedirect.tsx} (92%) diff --git a/site/src/contexts/auth/RequirePermission.tsx b/site/src/contexts/auth/RequirePermission.tsx index 50dbd0232ab88..d1c68ae50b919 100644 --- a/site/src/contexts/auth/RequirePermission.tsx +++ b/site/src/contexts/auth/RequirePermission.tsx @@ -14,7 +14,8 @@ export const RequirePermission: FC = ({ isFeatureVisible, }) => { if (!isFeatureVisible) { - return ; + // return ; + return

oh fuck

; } return <>{children}; diff --git a/site/src/modules/management/OrganizationSettingsLayout.tsx b/site/src/modules/management/OrganizationSettingsLayout.tsx index 6c7114037af76..fd7a1f062a9d0 100644 --- a/site/src/modules/management/OrganizationSettingsLayout.tsx +++ b/site/src/modules/management/OrganizationSettingsLayout.tsx @@ -61,12 +61,12 @@ const OrganizationSettingsLayout: FC = () => { organizationsPermissions(organizations?.map((o) => o.id)), ); - if (orgPermissionsQuery.isLoading) { - return ; + if (orgPermissionsQuery.isError) { + return ; } if (!orgPermissionsQuery.data) { - return ; + return ; } const viewableOrganizations = organizations.filter((org) => @@ -84,54 +84,52 @@ const OrganizationSettingsLayout: FC = () => { } return ( - - -
- - - - Admin Settings - - - - - Organizations - - - {organization && ( - <> - - - - - {organization.display_name} - - - - )} - - -
-
- }> - - -
+ +
+ + + + Admin Settings + + + + + Organizations + + + {organization && ( + <> + + + + + {organization.display_name} + + + + )} + + +
+
+ }> + +
- - +
+
); }; diff --git a/site/src/modules/management/OrganizationSidebarView.tsx b/site/src/modules/management/OrganizationSidebarView.tsx index 31dab067aebc0..e0f6ffb1f504e 100644 --- a/site/src/modules/management/OrganizationSidebarView.tsx +++ b/site/src/modules/management/OrganizationSidebarView.tsx @@ -47,6 +47,8 @@ export const OrganizationSidebarView: FC = ({ ] : organizations; + console.log(organizations); + const [isPopoverOpen, setIsPopoverOpen] = useState(false); const navigate = useNavigate(); @@ -60,16 +62,20 @@ export const OrganizationSidebarView: FC = ({ className="w-60 justify-between p-2 h-11" >
- {activeOrganization && ( - + {activeOrganization ? ( + <> + + + {activeOrganization.display_name || activeOrganization.name} + + + ) : ( + No organization selected )} - - {activeOrganization?.display_name || activeOrganization?.name} -
@@ -78,7 +84,7 @@ export const OrganizationSidebarView: FC = ({ - {sortedOrganizations.length > 1 ? ( + {sortedOrganizations.length > (activeOrganization ? 1 : 0) ? (
{sortedOrganizations.map((organization) => ( }, action: "update", }, - editOrganization: { + editSettings: { object: { resource_type: "organization", organization_id: organizationId, @@ -87,6 +87,13 @@ export const organizationPermissionChecks = (organizationId: string) => }, action: "read", }, + editIdpSyncSettings: { + object: { + resource_type: "idpsync_settings", + organization_id: organizationId, + }, + action: "update", + }, }) as const satisfies Record; /** @@ -116,8 +123,9 @@ export const canEditOrganization = ( permissions !== undefined && (permissions.editMembers || permissions.editGroups || - permissions.editOrganization || + permissions.editSettings || permissions.assignOrgRoles || + permissions.editIdpSyncSettings || permissions.createOrgRoles) ); }; @@ -151,12 +159,12 @@ export const anyOrganizationPermissionChecks = { }, action: "assign", }, - editAnyIdpSyncSettings: { + viewAnyIdpSyncSettings: { object: { resource_type: "idpsync_settings", any_org: true, }, - action: "update", + action: "read", }, editAnySettings: { object: { @@ -179,7 +187,7 @@ export const canViewAnyOrganization = ( (permissions.viewAnyMembers || permissions.editAnyGroups || permissions.assignAnyRoles || - permissions.editAnyIdpSyncSettings || + permissions.viewAnyIdpSyncSettings || permissions.editAnySettings) ); }; diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationMembersPage.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationMembersPage.tsx index 7d3aa534ca4a0..196bbf410e73d 100644 --- a/site/src/pages/OrganizationSettingsPage/OrganizationMembersPage.tsx +++ b/site/src/pages/OrganizationSettingsPage/OrganizationMembersPage.tsx @@ -21,6 +21,7 @@ import { useMutation, useQuery, useQueryClient } from "react-query"; import { useParams } from "react-router-dom"; import { pageTitle } from "utils/page"; import { OrganizationMembersPageView } from "./OrganizationMembersPageView"; +import { EmptyState } from "components/EmptyState/EmptyState"; const OrganizationMembersPage: FC = () => { const queryClient = useQueryClient(); @@ -54,11 +55,11 @@ const OrganizationMembersPage: FC = () => { const [memberToDelete, setMemberToDelete] = useState(); - if (!organizationPermissions) { - return ; + if (!organization || !organizationPermissions) { + return ; } - const helmet = organization && ( + const helmet = ( {pageTitle("Members", organization.display_name || organization.name)} diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationSettingsPage.test.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationRedirect.test.tsx similarity index 58% rename from site/src/pages/OrganizationSettingsPage/OrganizationSettingsPage.test.tsx rename to site/src/pages/OrganizationSettingsPage/OrganizationRedirect.test.tsx index 2978702ab9651..96e0110d21a80 100644 --- a/site/src/pages/OrganizationSettingsPage/OrganizationSettingsPage.test.tsx +++ b/site/src/pages/OrganizationSettingsPage/OrganizationRedirect.test.tsx @@ -10,19 +10,29 @@ import { waitForLoaderToBeRemoved, } from "testHelpers/renderHelpers"; import { server } from "testHelpers/server"; -import OrganizationSettingsPage from "./OrganizationSettingsPage"; +import OrganizationRedirect from "./OrganizationRedirect"; jest.spyOn(console, "error").mockImplementation(() => {}); const renderPage = async () => { - renderWithOrganizationSettingsLayout(<OrganizationSettingsPage />, { - route: "/organizations", - path: "/organizations/:organization?", - }); + const { router } = renderWithOrganizationSettingsLayout( + <OrganizationRedirect />, + { + route: "/organizations", + path: "/organizations", + extraRoutes: [ + { + path: "/organizations/:organization", + element: <h1>Organization Settings</h1>, + }, + ], + }, + ); await waitForLoaderToBeRemoved(); + return router; }; -describe("OrganizationSettingsPage", () => { +describe("OrganizationRedirect", () => { it("has no editable organizations", async () => { server.use( http.get("/api/v2/entitlements", () => { @@ -32,9 +42,7 @@ describe("OrganizationSettingsPage", () => { return HttpResponse.json([MockDefaultOrganization, MockOrganization2]); }), http.post("/api/v2/authcheck", async () => { - return HttpResponse.json({ - viewDeploymentValues: true, - }); + return HttpResponse.json({}); }), ); await renderPage(); @@ -52,16 +60,19 @@ describe("OrganizationSettingsPage", () => { }), http.post("/api/v2/authcheck", async () => { return HttpResponse.json({ - [`${MockDefaultOrganization.id}.editOrganization`]: true, - [`${MockOrganization2.id}.editOrganization`]: true, - viewDeploymentValues: true, + viewAnyMembers: true, + [`${MockDefaultOrganization.id}.viewMembers`]: true, + [`${MockDefaultOrganization.id}.editMembers`]: true, + [`${MockOrganization2.id}.viewMembers`]: true, + [`${MockOrganization2.id}.editMembers`]: true, }); }), ); - await renderPage(); - const form = screen.getByTestId("org-settings-form"); - expect(within(form).getByRole("textbox", { name: "Slug" })).toHaveValue( - MockDefaultOrganization.name, + const router = await renderPage(); + const form = screen.getByText("Organization Settings"); + expect(form).toBeInTheDocument(); + expect(router.state.location.pathname).toBe( + `/organizations/${MockDefaultOrganization.name}`, ); }); @@ -75,15 +86,18 @@ describe("OrganizationSettingsPage", () => { }), http.post("/api/v2/authcheck", async () => { return HttpResponse.json({ - [`${MockOrganization2.id}.editOrganization`]: true, - viewDeploymentValues: true, + viewAnyMembers: true, + [`${MockDefaultOrganization.id}.viewMembers`]: true, + [`${MockOrganization2.id}.viewMembers`]: true, + [`${MockOrganization2.id}.editMembers`]: true, }); }), ); - await renderPage(); - const form = screen.getByTestId("org-settings-form"); - expect(within(form).getByRole("textbox", { name: "Slug" })).toHaveValue( - MockOrganization2.name, + const router = await renderPage(); + const form = screen.getByText("Organization Settings"); + expect(form).toBeInTheDocument(); + expect(router.state.location.pathname).toBe( + `/organizations/${MockOrganization2.name}`, ); }); }); diff --git a/site/src/pages/OrganizationSettingsPage/DefaultOrganizationRedirect.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationRedirect.tsx similarity index 92% rename from site/src/pages/OrganizationSettingsPage/DefaultOrganizationRedirect.tsx rename to site/src/pages/OrganizationSettingsPage/OrganizationRedirect.tsx index d69fc50aa1491..b862ad41dc883 100644 --- a/site/src/pages/OrganizationSettingsPage/DefaultOrganizationRedirect.tsx +++ b/site/src/pages/OrganizationSettingsPage/OrganizationRedirect.tsx @@ -4,7 +4,7 @@ import { canEditOrganization } from "modules/management/organizationPermissions" import type { FC } from "react"; import { Navigate } from "react-router-dom"; -const DefaultOrganizationRedirect: FC = () => { +const OrganizationRedirect: FC = () => { const { organizations, organizationPermissionsByOrganizationId: organizationPermissions, @@ -27,4 +27,4 @@ const DefaultOrganizationRedirect: FC = () => { return <EmptyState message="No organizations found" />; }; -export default DefaultOrganizationRedirect; +export default OrganizationRedirect; diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationSettingsPage.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationSettingsPage.tsx index 06f286f0298ed..8119bb94b7e0a 100644 --- a/site/src/pages/OrganizationSettingsPage/OrganizationSettingsPage.tsx +++ b/site/src/pages/OrganizationSettingsPage/OrganizationSettingsPage.tsx @@ -15,13 +15,10 @@ import { Navigate, useNavigate, useParams } from "react-router-dom"; import { OrganizationSettingsPageView } from "./OrganizationSettingsPageView"; const OrganizationSettingsPage: FC = () => { - const { organization: organizationName } = useParams() as { - organization?: string; - }; - const { organizations } = useOrganizationSettings(); - const navigate = useNavigate(); const queryClient = useQueryClient(); + const { organization, organizationPermissions } = useOrganizationSettings(); + const updateOrganizationMutation = useMutation( updateOrganization(queryClient), ); @@ -29,33 +26,10 @@ const OrganizationSettingsPage: FC = () => { deleteOrganization(queryClient), ); - const organization = organizations?.find((o) => o.name === organizationName); - const permissionsQuery = useQuery( - organizationsPermissions(organizations?.map((o) => o.id)), - ); - - if (permissionsQuery.isLoading) { - return <Loader />; - } - - const permissions = permissionsQuery.data; - if (permissionsQuery.error || !permissions) { - return <ErrorAlert error={permissionsQuery.error} />; - } - - if (!organization) { + if (!organization || !organizationPermissions?.editSettings) { return <EmptyState message="Organization not found" />; } - // The user may not be able to edit this org but they can still see it because - // they can edit members, etc. In this case they will be shown a read-only - // summary page instead of the settings form. - // Similarly, if the feature is not entitled then the user will not be able to - // edit the organization. - if (!permissions[organization.id]?.editOrganization) { - return <Navigate to=".." replace />; - } - const error = updateOrganizationMutation.error ?? deleteOrganizationMutation.error; diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationSettingsPageView.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationSettingsPageView.tsx index 7dcf23bf4a4a6..6ce41354973fa 100644 --- a/site/src/pages/OrganizationSettingsPage/OrganizationSettingsPageView.tsx +++ b/site/src/pages/OrganizationSettingsPage/OrganizationSettingsPageView.tsx @@ -75,7 +75,6 @@ export const OrganizationSettingsPageView: FC< )} <HorizontalForm - data-testid="org-settings-form" onSubmit={form.handleSubmit} aria-label="Organization settings form" > diff --git a/site/src/router.tsx b/site/src/router.tsx index c2a460844af78..60b461fdba2ec 100644 --- a/site/src/router.tsx +++ b/site/src/router.tsx @@ -228,8 +228,8 @@ const AddNewLicensePage = lazy( "./pages/DeploymentSettingsPage/LicensesSettingsPage/AddNewLicensePage" ), ); -const DefaultOrganizationRedirect = lazy( - () => import("./pages/OrganizationSettingsPage/DefaultOrganizationRedirect"), +const OrganizationRedirect = lazy( + () => import("./pages/OrganizationSettingsPage/OrganizationRedirect"), ); const CreateOrganizationPage = lazy( @@ -416,7 +416,7 @@ export const router = createBrowserRouter( <Route path="new" element={<CreateOrganizationPage />} /> {/* General settings for the default org can omit the organization name */} - <Route index element={<DefaultOrganizationRedirect />} /> + <Route index element={<OrganizationRedirect />} /> <Route path=":organization" element={<OrganizationSidebarLayout />}> <Route index element={<OrganizationMembersPage />} /> diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index a8df56dc5affd..37ae681977c2e 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -2836,12 +2836,13 @@ export const MockOrganizationPermissions: OrganizationPermissions = { createGroup: true, viewGroups: true, editGroups: true, - editOrganization: true, + editSettings: true, viewOrgRoles: true, createOrgRoles: true, assignOrgRoles: true, viewProvisioners: true, viewIdpSyncSettings: true, + editIdpSyncSettings: true, }; export const MockNoOrganizationPermissions: OrganizationPermissions = { @@ -2850,12 +2851,13 @@ export const MockNoOrganizationPermissions: OrganizationPermissions = { createGroup: false, viewGroups: false, editGroups: false, - editOrganization: false, + editSettings: false, viewOrgRoles: false, createOrgRoles: false, assignOrgRoles: false, viewProvisioners: false, viewIdpSyncSettings: false, + editIdpSyncSettings: false, }; export const MockNoPermissions: Permissions = { From d7990cfcbc2aeb97da6d2e8e30c526c2d805b501 Mon Sep 17 00:00:00 2001 From: McKayla Washburn <mckayla@hey.com> Date: Sat, 8 Feb 2025 00:22:29 +0000 Subject: [PATCH 07/21] =?UTF-8?q?=F0=9F=A7=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- site/src/modules/management/OrganizationSettingsLayout.tsx | 6 ++---- .../CustomRolesPage/CustomRolesPage.tsx | 1 + .../CustomRolesPage/CustomRolesPageView.tsx | 4 +++- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/site/src/modules/management/OrganizationSettingsLayout.tsx b/site/src/modules/management/OrganizationSettingsLayout.tsx index fd7a1f062a9d0..c8b88e35952c2 100644 --- a/site/src/modules/management/OrganizationSettingsLayout.tsx +++ b/site/src/modules/management/OrganizationSettingsLayout.tsx @@ -1,5 +1,5 @@ import { organizationsPermissions } from "api/queries/organizations"; -import type { AuthorizationResponse, Organization } from "api/typesGenerated"; +import type { Organization } from "api/typesGenerated"; import { ErrorAlert } from "components/Alert/ErrorAlert"; import { Avatar } from "components/Avatar/Avatar"; import { @@ -10,8 +10,6 @@ import { BreadcrumbSeparator, } from "components/Breadcrumb/Breadcrumb"; import { Loader } from "components/Loader/Loader"; -import { useAuthenticated } from "contexts/auth/RequireAuth"; -import { RequirePermission } from "contexts/auth/RequirePermission"; import { useDashboard } from "modules/dashboard/useDashboard"; import NotFoundPage from "pages/404Page/404Page"; import { type FC, Suspense, createContext, useContext } from "react"; @@ -48,7 +46,7 @@ export const useOrganizationSettings = (): OrganizationSettingsValue => { }; const OrganizationSettingsLayout: FC = () => { - const { organizations, canViewOrganizationSettings } = useDashboard(); + const { organizations } = useDashboard(); const { organization: orgName } = useParams() as { organization?: string; }; diff --git a/site/src/pages/OrganizationSettingsPage/CustomRolesPage/CustomRolesPage.tsx b/site/src/pages/OrganizationSettingsPage/CustomRolesPage/CustomRolesPage.tsx index 096675adb1da5..362448368d1a6 100644 --- a/site/src/pages/OrganizationSettingsPage/CustomRolesPage/CustomRolesPage.tsx +++ b/site/src/pages/OrganizationSettingsPage/CustomRolesPage/CustomRolesPage.tsx @@ -74,6 +74,7 @@ export const CustomRolesPage: FC = () => { customRoles={customRoles} onDeleteRole={setRoleToDelete} canAssignOrgRole={organizationPermissions.assignOrgRoles} + canCreateOrgRole={organizationPermissions.createOrgRoles} isCustomRolesEnabled={isCustomRolesEnabled} /> diff --git a/site/src/pages/OrganizationSettingsPage/CustomRolesPage/CustomRolesPageView.tsx b/site/src/pages/OrganizationSettingsPage/CustomRolesPage/CustomRolesPageView.tsx index c1aa2223703d2..1bb1f049aa804 100644 --- a/site/src/pages/OrganizationSettingsPage/CustomRolesPage/CustomRolesPageView.tsx +++ b/site/src/pages/OrganizationSettingsPage/CustomRolesPage/CustomRolesPageView.tsx @@ -35,6 +35,7 @@ interface CustomRolesPageViewProps { customRoles: AssignableRoles[] | undefined; onDeleteRole: (role: Role) => void; canAssignOrgRole: boolean; + canCreateOrgRole: boolean; isCustomRolesEnabled: boolean; } @@ -43,6 +44,7 @@ export const CustomRolesPageView: FC<CustomRolesPageViewProps> = ({ customRoles, onDeleteRole, canAssignOrgRole, + canCreateOrgRole, isCustomRolesEnabled, }) => { return ( @@ -66,7 +68,7 @@ export const CustomRolesPageView: FC<CustomRolesPageViewProps> = ({ permissions. </span> </span> - {canAssignOrgRole && isCustomRolesEnabled && ( + {canCreateOrgRole && isCustomRolesEnabled && ( <Button component={RouterLink} startIcon={<AddIcon />} to="create"> Create custom role </Button> From acf5cc7e033f9218a3127759a1ce90037e3bedf6 Mon Sep 17 00:00:00 2001 From: McKayla Washburn <mckayla@hey.com> Date: Sat, 8 Feb 2025 00:25:33 +0000 Subject: [PATCH 08/21] :) --- site/src/modules/management/OrganizationSidebarView.tsx | 2 -- .../pages/OrganizationSettingsPage/OrganizationMembersPage.tsx | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/site/src/modules/management/OrganizationSidebarView.tsx b/site/src/modules/management/OrganizationSidebarView.tsx index e0f6ffb1f504e..2e281641cead1 100644 --- a/site/src/modules/management/OrganizationSidebarView.tsx +++ b/site/src/modules/management/OrganizationSidebarView.tsx @@ -47,8 +47,6 @@ export const OrganizationSidebarView: FC<SidebarProps> = ({ ] : organizations; - console.log(organizations); - const [isPopoverOpen, setIsPopoverOpen] = useState(false); const navigate = useNavigate(); diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationMembersPage.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationMembersPage.tsx index 196bbf410e73d..a236e2bc44c37 100644 --- a/site/src/pages/OrganizationSettingsPage/OrganizationMembersPage.tsx +++ b/site/src/pages/OrganizationSettingsPage/OrganizationMembersPage.tsx @@ -10,6 +10,7 @@ import { import { organizationRoles } from "api/queries/roles"; import type { OrganizationMemberWithUserData, User } from "api/typesGenerated"; import { ConfirmDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog"; +import { EmptyState } from "components/EmptyState/EmptyState"; import { displayError, displaySuccess } from "components/GlobalSnackbar/utils"; import { Loader } from "components/Loader/Loader"; import { Stack } from "components/Stack/Stack"; @@ -21,7 +22,6 @@ import { useMutation, useQuery, useQueryClient } from "react-query"; import { useParams } from "react-router-dom"; import { pageTitle } from "utils/page"; import { OrganizationMembersPageView } from "./OrganizationMembersPageView"; -import { EmptyState } from "components/EmptyState/EmptyState"; const OrganizationMembersPage: FC = () => { const queryClient = useQueryClient(); From c8db4f73a20ed9c85a06ec62802a1efd38f64596 Mon Sep 17 00:00:00 2001 From: McKayla Washburn <mckayla@hey.com> Date: Tue, 11 Feb 2025 22:35:01 +0000 Subject: [PATCH 09/21] yay polish time --- coderd/rbac/roles.go | 17 ++++++++--------- .../Navbar/UserDropdown/UserDropdownContent.tsx | 7 ++++--- .../management/OrganizationSidebarView.tsx | 6 +++--- .../OrganizationSettingsPage.tsx | 8 ++------ 4 files changed, 17 insertions(+), 21 deletions(-) diff --git a/coderd/rbac/roles.go b/coderd/rbac/roles.go index 7fb141e557e96..d9023ae785d5f 100644 --- a/coderd/rbac/roles.go +++ b/coderd/rbac/roles.go @@ -297,18 +297,17 @@ func ReloadBuiltinRoles(opts *RoleOptions) { Identifier: RoleAuditor(), DisplayName: "Auditor", Site: Permissions(map[string][]policy.Action{ - // Should be able to read all template details, even in orgs they - // are not in. - ResourceTemplate.Type: {policy.ActionRead, policy.ActionViewInsights}, - ResourceAuditLog.Type: {policy.ActionRead}, - ResourceUser.Type: {policy.ActionRead}, - ResourceGroup.Type: {policy.ActionRead}, - ResourceGroupMember.Type: {policy.ActionRead}, + ResourceAuditLog.Type: {policy.ActionRead}, + // Allow auditors to see the resources that audit logs reflect. + ResourceTemplate.Type: {policy.ActionRead, policy.ActionViewInsights}, + ResourceUser.Type: {policy.ActionRead}, + ResourceGroup.Type: {policy.ActionRead}, + ResourceGroupMember.Type: {policy.ActionRead}, + ResourceOrganization.Type: {policy.ActionRead}, + ResourceOrganizationMember.Type: {policy.ActionRead}, // Allow auditors to query deployment stats and insights. ResourceDeploymentStats.Type: {policy.ActionRead}, ResourceDeploymentConfig.Type: {policy.ActionRead}, - // Org roles are not really used yet, so grant the perm at the site level. - ResourceOrganizationMember.Type: {policy.ActionRead}, }), Org: map[string][]Permission{}, User: []Permission{}, diff --git a/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdownContent.tsx b/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdownContent.tsx index 90ea1dab74a67..9eb89407dea31 100644 --- a/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdownContent.tsx +++ b/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdownContent.tsx @@ -22,6 +22,7 @@ import { Stack } from "components/Stack/Stack"; import { usePopover } from "components/deprecated/Popover/Popover"; import type { FC } from "react"; import { Link } from "react-router-dom"; + export const Language = { accountLabel: "Account", signOutLabel: "Sign Out", @@ -129,7 +130,7 @@ export const UserDropdownContent: FC<UserDropdownContentProps> = ({ </a> </Tooltip> - {Boolean(buildInfo?.deployment_id) && ( + {buildInfo?.deployment_id && ( <div css={css` font-size: 12px; @@ -145,11 +146,11 @@ export const UserDropdownContent: FC<UserDropdownContentProps> = ({ text-overflow: ellipsis; `} > - {buildInfo?.deployment_id} + {buildInfo.deployment_id} </div> </Tooltip> <CopyButton - text={buildInfo!.deployment_id} + text={buildInfo.deployment_id} buttonStyles={css` width: 16px; height: 16px; diff --git a/site/src/modules/management/OrganizationSidebarView.tsx b/site/src/modules/management/OrganizationSidebarView.tsx index 2e281641cead1..473935628aeb5 100644 --- a/site/src/modules/management/OrganizationSidebarView.tsx +++ b/site/src/modules/management/OrganizationSidebarView.tsx @@ -163,7 +163,7 @@ function urlForSubpage(organizationName: string, subpage = ""): string { interface OrganizationSettingsNavigationProps { organization: Organization; - orgPermissions: AuthorizationResponse; + orgPermissions: OrganizationPermissions; } const OrganizationSettingsNavigation: FC< @@ -184,7 +184,7 @@ const OrganizationSettingsNavigation: FC< Groups </SettingsSidebarNavItem> )} - {orgPermissions.assignOrgRole && ( + {orgPermissions.assignOrgRoles && ( <SettingsSidebarNavItem href={urlForSubpage(organization.name, "roles")} > @@ -205,7 +205,7 @@ const OrganizationSettingsNavigation: FC< IdP Sync </SettingsSidebarNavItem> )} - {orgPermissions.editOrganization && ( + {orgPermissions.editSettings && ( <SettingsSidebarNavItem href={urlForSubpage(organization.name, "settings")} > diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationSettingsPage.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationSettingsPage.tsx index 8119bb94b7e0a..13c339dcc3c09 100644 --- a/site/src/pages/OrganizationSettingsPage/OrganizationSettingsPage.tsx +++ b/site/src/pages/OrganizationSettingsPage/OrganizationSettingsPage.tsx @@ -1,17 +1,13 @@ import { deleteOrganization, - organizationsPermissions, updateOrganization, } from "api/queries/organizations"; -import type { AuthorizationResponse } from "api/typesGenerated"; -import { ErrorAlert } from "components/Alert/ErrorAlert"; import { EmptyState } from "components/EmptyState/EmptyState"; import { displaySuccess } from "components/GlobalSnackbar/utils"; -import { Loader } from "components/Loader/Loader"; import { useOrganizationSettings } from "modules/management/OrganizationSettingsLayout"; import type { FC } from "react"; -import { useMutation, useQuery, useQueryClient } from "react-query"; -import { Navigate, useNavigate, useParams } from "react-router-dom"; +import { useMutation, useQueryClient } from "react-query"; +import { useNavigate } from "react-router-dom"; import { OrganizationSettingsPageView } from "./OrganizationSettingsPageView"; const OrganizationSettingsPage: FC = () => { From 1bb613722f1eea6984c4f07ab8d567d5a0b16b64 Mon Sep 17 00:00:00 2001 From: McKayla Washburn <mckayla@hey.com> Date: Tue, 11 Feb 2025 22:35:33 +0000 Subject: [PATCH 10/21] =?UTF-8?q?=F0=9F=92=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- site/src/contexts/auth/RequirePermission.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/site/src/contexts/auth/RequirePermission.tsx b/site/src/contexts/auth/RequirePermission.tsx index d1c68ae50b919..50dbd0232ab88 100644 --- a/site/src/contexts/auth/RequirePermission.tsx +++ b/site/src/contexts/auth/RequirePermission.tsx @@ -14,8 +14,7 @@ export const RequirePermission: FC<RequirePermissionProps> = ({ isFeatureVisible, }) => { if (!isFeatureVisible) { - // return <Navigate to="/workspaces" />; - return <h1>oh fuck</h1>; + return <Navigate to="/workspaces" />; } return <>{children}</>; From 5cd198bb067acee80ade32d3f483581c67537ec0 Mon Sep 17 00:00:00 2001 From: McKayla Washburn <mckayla@hey.com> Date: Tue, 18 Feb 2025 19:33:39 +0000 Subject: [PATCH 11/21] =?UTF-8?q?=F0=9F=A7=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../CustomRolesPage/CreateEditRolePage.tsx | 7 ++++++- .../OrganizationMembersPage.test.tsx | 13 +++++++++---- .../OrganizationMembersPage.tsx | 1 - .../OrganizationMembersPageView.tsx | 1 + 4 files changed, 16 insertions(+), 6 deletions(-) diff --git a/site/src/pages/OrganizationSettingsPage/CustomRolesPage/CreateEditRolePage.tsx b/site/src/pages/OrganizationSettingsPage/CustomRolesPage/CreateEditRolePage.tsx index d073ed698ec66..cc275cc75e703 100644 --- a/site/src/pages/OrganizationSettingsPage/CustomRolesPage/CreateEditRolePage.tsx +++ b/site/src/pages/OrganizationSettingsPage/CustomRolesPage/CreateEditRolePage.tsx @@ -14,6 +14,7 @@ import { useMutation, useQuery, useQueryClient } from "react-query"; import { useNavigate, useParams } from "react-router-dom"; import { pageTitle } from "utils/page"; import CreateEditRolePageView from "./CreateEditRolePageView"; +import { ErrorAlert } from "components/Alert/ErrorAlert"; export const CreateEditRolePage: FC = () => { const queryClient = useQueryClient(); @@ -35,10 +36,14 @@ export const CreateEditRolePage: FC = () => { ); const role = roleData?.find((role) => role.name === roleName); - if (isLoading || !organizationPermissions) { + if (isLoading) { return <Loader />; } + if (!organizationPermissions) { + return <ErrorAlert error="Failed to load organization permissions" />; + } + return ( <> <Helmet> diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationMembersPage.test.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationMembersPage.test.tsx index 0c9c7d44bd15a..1270f78484dc7 100644 --- a/site/src/pages/OrganizationSettingsPage/OrganizationMembersPage.test.tsx +++ b/site/src/pages/OrganizationSettingsPage/OrganizationMembersPage.test.tsx @@ -6,6 +6,7 @@ import { MockEntitlementsWithMultiOrg, MockOrganization, MockOrganizationAuditorRole, + MockOrganizationPermissions, MockUser, } from "testHelpers/entities"; import { @@ -23,10 +24,14 @@ beforeEach(() => { return HttpResponse.json(MockEntitlementsWithMultiOrg); }), http.post("/api/v2/authcheck", async () => { - return HttpResponse.json({ - editMembers: true, - viewDeploymentValues: true, - }); + return HttpResponse.json( + Object.fromEntries( + Object.entries(MockOrganizationPermissions).map(([key, value]) => [ + `${MockOrganization.id}.${key}`, + value, + ]), + ), + ); }), ); }); diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationMembersPage.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationMembersPage.tsx index a236e2bc44c37..078ae1a0cbba8 100644 --- a/site/src/pages/OrganizationSettingsPage/OrganizationMembersPage.tsx +++ b/site/src/pages/OrganizationSettingsPage/OrganizationMembersPage.tsx @@ -12,7 +12,6 @@ import type { OrganizationMemberWithUserData, User } from "api/typesGenerated"; import { ConfirmDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog"; import { EmptyState } from "components/EmptyState/EmptyState"; import { displayError, displaySuccess } from "components/GlobalSnackbar/utils"; -import { Loader } from "components/Loader/Loader"; import { Stack } from "components/Stack/Stack"; import { useAuthenticated } from "contexts/auth/RequireAuth"; import { useOrganizationSettings } from "modules/management/OrganizationSettingsLayout"; diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationMembersPageView.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationMembersPageView.tsx index 72737a92c3ebe..f6c791484e425 100644 --- a/site/src/pages/OrganizationSettingsPage/OrganizationMembersPageView.tsx +++ b/site/src/pages/OrganizationSettingsPage/OrganizationMembersPageView.tsx @@ -79,6 +79,7 @@ export const OrganizationMembersPageView: FC< onSubmit={addMember} /> )} + <Table> <TableHeader> <TableRow> From 0cb6c4e6397c1940a2a58c30d385a756a327b988 Mon Sep 17 00:00:00 2001 From: McKayla Washburn <mckayla@hey.com> Date: Tue, 18 Feb 2025 19:43:56 +0000 Subject: [PATCH 12/21] of course --- .../CustomRolesPage/CreateEditRolePage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site/src/pages/OrganizationSettingsPage/CustomRolesPage/CreateEditRolePage.tsx b/site/src/pages/OrganizationSettingsPage/CustomRolesPage/CreateEditRolePage.tsx index cc275cc75e703..b9adbb44feb26 100644 --- a/site/src/pages/OrganizationSettingsPage/CustomRolesPage/CreateEditRolePage.tsx +++ b/site/src/pages/OrganizationSettingsPage/CustomRolesPage/CreateEditRolePage.tsx @@ -5,6 +5,7 @@ import { updateOrganizationRole, } from "api/queries/roles"; import type { CustomRoleRequest } from "api/typesGenerated"; +import { ErrorAlert } from "components/Alert/ErrorAlert"; import { displayError } from "components/GlobalSnackbar/utils"; import { Loader } from "components/Loader/Loader"; import { useOrganizationSettings } from "modules/management/OrganizationSettingsLayout"; @@ -14,7 +15,6 @@ import { useMutation, useQuery, useQueryClient } from "react-query"; import { useNavigate, useParams } from "react-router-dom"; import { pageTitle } from "utils/page"; import CreateEditRolePageView from "./CreateEditRolePageView"; -import { ErrorAlert } from "components/Alert/ErrorAlert"; export const CreateEditRolePage: FC = () => { const queryClient = useQueryClient(); From b4227b2e4466bb2c8e7b3fe329df912075894b94 Mon Sep 17 00:00:00 2001 From: McKayla Washburn <mckayla@hey.com> Date: Tue, 18 Feb 2025 22:16:41 +0000 Subject: [PATCH 13/21] fix some stories --- .../OrganizationSidebarView.stories.tsx | 13 +-- .../management/OrganizationSidebarView.tsx | 2 +- .../CustomRolesPageView.stories.tsx | 33 ++----- .../OrganizationSettingsPage.stories.tsx | 98 ------------------- 4 files changed, 16 insertions(+), 130 deletions(-) delete mode 100644 site/src/pages/OrganizationSettingsPage/OrganizationSettingsPage.stories.tsx diff --git a/site/src/modules/management/OrganizationSidebarView.stories.tsx b/site/src/modules/management/OrganizationSidebarView.stories.tsx index f6c7c204c451c..21869081a49c9 100644 --- a/site/src/modules/management/OrganizationSidebarView.stories.tsx +++ b/site/src/modules/management/OrganizationSidebarView.stories.tsx @@ -26,12 +26,6 @@ const meta: Meta<typeof OrganizationSidebarView> = { export default meta; type Story = StoryObj<typeof OrganizationSidebarView>; -export const LoadingOrganizations: Story = { - args: { - organizations: undefined, - }, -}; - export const NoCreateOrg: Story = { args: { activeOrganization: MockOrganization, @@ -164,8 +158,11 @@ export const SelectedOrgUserAdmin: Story = { activeOrganization: MockOrganization, orgPermissions: { ...MockNoOrganizationPermissions, - editMembers: true, - editGroups: true, + viewMembers: true, + viewGroups: true, + viewOrgRoles: true, + viewProvisioners: true, + viewIdpSyncSettings: true, }, permissions: { ...MockPermissions, diff --git a/site/src/modules/management/OrganizationSidebarView.tsx b/site/src/modules/management/OrganizationSidebarView.tsx index 473935628aeb5..06b148016bf36 100644 --- a/site/src/modules/management/OrganizationSidebarView.tsx +++ b/site/src/modules/management/OrganizationSidebarView.tsx @@ -184,7 +184,7 @@ const OrganizationSettingsNavigation: FC< Groups </SettingsSidebarNavItem> )} - {orgPermissions.assignOrgRoles && ( + {orgPermissions.viewOrgRoles && ( <SettingsSidebarNavItem href={urlForSubpage(organization.name, "roles")} > diff --git a/site/src/pages/OrganizationSettingsPage/CustomRolesPage/CustomRolesPageView.stories.tsx b/site/src/pages/OrganizationSettingsPage/CustomRolesPage/CustomRolesPageView.stories.tsx index f37e23a1e989a..79319c888647f 100644 --- a/site/src/pages/OrganizationSettingsPage/CustomRolesPage/CustomRolesPageView.stories.tsx +++ b/site/src/pages/OrganizationSettingsPage/CustomRolesPage/CustomRolesPageView.stories.tsx @@ -8,44 +8,38 @@ import { CustomRolesPageView } from "./CustomRolesPageView"; const meta: Meta<typeof CustomRolesPageView> = { title: "pages/OrganizationCustomRolesPage", component: CustomRolesPageView, + args: { + builtInRoles: [MockRoleWithOrgPermissions], + customRoles: [MockRoleWithOrgPermissions], + canAssignOrgRole: true, + canCreateOrgRole: true, + isCustomRolesEnabled: true, + }, }; export default meta; type Story = StoryObj<typeof CustomRolesPageView>; +export const Enabled: Story = {}; + export const NotEnabled: Story = { args: { - builtInRoles: [MockRoleWithOrgPermissions], - customRoles: [MockRoleWithOrgPermissions], - canAssignOrgRole: true, isCustomRolesEnabled: false, }, }; export const NotEnabledEmptyTable: Story = { args: { - builtInRoles: [MockRoleWithOrgPermissions], customRoles: [], canAssignOrgRole: true, isCustomRolesEnabled: false, }, }; -export const Enabled: Story = { - args: { - builtInRoles: [MockRoleWithOrgPermissions], - customRoles: [MockRoleWithOrgPermissions], - canAssignOrgRole: true, - isCustomRolesEnabled: true, - }, -}; - export const RoleWithoutPermissions: Story = { args: { builtInRoles: [MockOrganizationAuditorRole], customRoles: [MockOrganizationAuditorRole], - canAssignOrgRole: true, - isCustomRolesEnabled: true, }, }; @@ -58,26 +52,19 @@ export const EmptyDisplayName: Story = { display_name: "", }, ], - builtInRoles: [MockRoleWithOrgPermissions], - canAssignOrgRole: true, - isCustomRolesEnabled: true, }, }; export const EmptyTableUserWithoutPermission: Story = { args: { - builtInRoles: [MockRoleWithOrgPermissions], customRoles: [], canAssignOrgRole: false, - isCustomRolesEnabled: true, + canCreateOrgRole: false, }, }; export const EmptyTableUserWithPermission: Story = { args: { - builtInRoles: [MockRoleWithOrgPermissions], customRoles: [], - canAssignOrgRole: true, - isCustomRolesEnabled: true, }, }; diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationSettingsPage.stories.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationSettingsPage.stories.tsx deleted file mode 100644 index f1b0f8c93e81b..0000000000000 --- a/site/src/pages/OrganizationSettingsPage/OrganizationSettingsPage.stories.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react"; -import { reactRouterParameters } from "storybook-addon-remix-react-router"; -import { - MockDefaultOrganization, - MockOrganization, - MockOrganization2, - MockUser, -} from "testHelpers/entities"; -import { - withAuthProvider, - withDashboardProvider, - withOrganizationSettingsProvider, -} from "testHelpers/storybook"; -import OrganizationSettingsPage from "./OrganizationSettingsPage"; - -const meta: Meta<typeof OrganizationSettingsPage> = { - title: "pages/OrganizationSettingsPage", - component: OrganizationSettingsPage, - decorators: [ - withAuthProvider, - withDashboardProvider, - withOrganizationSettingsProvider, - ], - parameters: { - showOrganizations: true, - user: MockUser, - features: ["multiple_organizations"], - permissions: { viewDeploymentValues: true }, - queries: [ - { - key: ["organizations", [MockDefaultOrganization.id], "permissions"], - data: {}, - }, - ], - }, -}; - -export default meta; -type Story = StoryObj<typeof OrganizationSettingsPage>; - -export const NoRedirectableOrganizations: Story = {}; - -export const OrganizationDoesNotExist: Story = { - parameters: { - reactRouter: reactRouterParameters({ - location: { pathParams: { organization: "does-not-exist" } }, - routing: { path: "/organizations/:organization" }, - }), - }, -}; - -export const CannotEditOrganization: Story = { - parameters: { - reactRouter: reactRouterParameters({ - location: { pathParams: { organization: MockDefaultOrganization.name } }, - routing: { path: "/organizations/:organization" }, - }), - }, -}; - -export const CanEditOrganization: Story = { - parameters: { - reactRouter: reactRouterParameters({ - location: { pathParams: { organization: MockDefaultOrganization.name } }, - routing: { path: "/organizations/:organization" }, - }), - queries: [ - { - key: ["organizations", [MockDefaultOrganization.id], "permissions"], - data: { - [MockDefaultOrganization.id]: { - editOrganization: true, - }, - }, - }, - ], - }, -}; - -export const CanEditOrganizationNotEntitled: Story = { - parameters: { - reactRouter: reactRouterParameters({ - location: { pathParams: { organization: MockDefaultOrganization.name } }, - routing: { path: "/organizations/:organization" }, - }), - features: [], - queries: [ - { - key: ["organizations", [MockDefaultOrganization.id], "permissions"], - data: { - [MockDefaultOrganization.id]: { - editOrganization: true, - }, - }, - }, - ], - }, -}; From 5f6b24820546bf2a9ceda424aad52bb1e82ac25c Mon Sep 17 00:00:00 2001 From: McKayla Washburn <mckayla@hey.com> Date: Tue, 18 Feb 2025 23:17:58 +0000 Subject: [PATCH 14/21] a few more permissions fixes --- coderd/rbac/roles.go | 14 +++++++------- .../management/OrganizationSettingsLayout.tsx | 14 ++++++++++++-- .../modules/management/OrganizationSidebarView.tsx | 2 +- .../modules/management/organizationPermissions.tsx | 7 +++++++ .../ProvisionersPage/ProvisionersPage.tsx | 4 ++-- site/src/testHelpers/entities.ts | 2 ++ 6 files changed, 31 insertions(+), 12 deletions(-) diff --git a/coderd/rbac/roles.go b/coderd/rbac/roles.go index d9023ae785d5f..e1399aded95d0 100644 --- a/coderd/rbac/roles.go +++ b/coderd/rbac/roles.go @@ -324,11 +324,10 @@ func ReloadBuiltinRoles(opts *RoleOptions) { // CRUD to provisioner daemons for now. ResourceProvisionerDaemon.Type: {policy.ActionCreate, policy.ActionRead, policy.ActionUpdate, policy.ActionDelete}, // Needs to read all organizations since - ResourceOrganization.Type: {policy.ActionRead}, - ResourceUser.Type: {policy.ActionRead}, - ResourceGroup.Type: {policy.ActionRead}, - ResourceGroupMember.Type: {policy.ActionRead}, - // Org roles are not really used yet, so grant the perm at the site level. + ResourceUser.Type: {policy.ActionRead}, + ResourceGroup.Type: {policy.ActionRead}, + ResourceGroupMember.Type: {policy.ActionRead}, + ResourceOrganization.Type: {policy.ActionRead}, ResourceOrganizationMember.Type: {policy.ActionRead}, }), Org: map[string][]Permission{}, @@ -347,10 +346,11 @@ func ReloadBuiltinRoles(opts *RoleOptions) { policy.ActionCreate, policy.ActionRead, policy.ActionUpdate, policy.ActionDelete, policy.ActionUpdatePersonal, policy.ActionReadPersonal, }, + ResourceGroup.Type: {policy.ActionCreate, policy.ActionRead, policy.ActionUpdate, policy.ActionDelete}, + ResourceGroupMember.Type: {policy.ActionRead}, + ResourceOrganization.Type: {policy.ActionRead}, // Full perms to manage org members ResourceOrganizationMember.Type: {policy.ActionCreate, policy.ActionRead, policy.ActionUpdate, policy.ActionDelete}, - ResourceGroup.Type: {policy.ActionCreate, policy.ActionRead, policy.ActionUpdate, policy.ActionDelete}, - ResourceGroupMember.Type: {policy.ActionRead}, // Manage org membership based on OIDC claims ResourceIdpsyncSettings.Type: {policy.ActionRead, policy.ActionUpdate}, }), diff --git a/site/src/modules/management/OrganizationSettingsLayout.tsx b/site/src/modules/management/OrganizationSettingsLayout.tsx index c8b88e35952c2..906a8aaf42ff5 100644 --- a/site/src/modules/management/OrganizationSettingsLayout.tsx +++ b/site/src/modules/management/OrganizationSettingsLayout.tsx @@ -19,6 +19,8 @@ import { type OrganizationPermissions, canViewOrganization, } from "./organizationPermissions"; +import { Paywall } from "components/Paywall/Paywall"; +import { docs } from "utils/docs"; export const OrganizationSettingsContext = createContext< OrganizationSettingsValue | undefined @@ -46,7 +48,7 @@ export const useOrganizationSettings = (): OrganizationSettingsValue => { }; const OrganizationSettingsLayout: FC = () => { - const { organizations } = useDashboard(); + const { organizations, showOrganizations } = useDashboard(); const { organization: orgName } = useParams() as { organization?: string; }; @@ -123,7 +125,15 @@ const OrganizationSettingsLayout: FC = () => { <hr className="h-px border-none bg-border" /> <div className="px-10 max-w-screen-2xl"> <Suspense fallback={<Loader />}> - <Outlet /> + {showOrganizations ? ( + <Outlet /> + ) : ( + <Paywall + message="Organizations" + description="Organizations can be used to segment and isolate resources inside a Coder deployment. You need a Premium license to use this feature." + documentationLink={docs("/admin/users/organizations")} + /> + )} </Suspense> </div> </div> diff --git a/site/src/modules/management/OrganizationSidebarView.tsx b/site/src/modules/management/OrganizationSidebarView.tsx index 06b148016bf36..a875579ceab67 100644 --- a/site/src/modules/management/OrganizationSidebarView.tsx +++ b/site/src/modules/management/OrganizationSidebarView.tsx @@ -191,7 +191,7 @@ const OrganizationSettingsNavigation: FC< Roles </SettingsSidebarNavItem> )} - {orgPermissions.viewProvisioners && ( + {orgPermissions.viewProvisionerJobs && ( <SettingsSidebarNavItem href={urlForSubpage(organization.name, "provisioners")} > diff --git a/site/src/modules/management/organizationPermissions.tsx b/site/src/modules/management/organizationPermissions.tsx index a646d492f0640..2a414856105a4 100644 --- a/site/src/modules/management/organizationPermissions.tsx +++ b/site/src/modules/management/organizationPermissions.tsx @@ -80,6 +80,13 @@ export const organizationPermissionChecks = (organizationId: string) => }, action: "read", }, + viewProvisionerJobs: { + object: { + resource_type: "provisioner_jobs", + organization_id: organizationId, + }, + action: "read", + }, viewIdpSyncSettings: { object: { resource_type: "idpsync_settings", diff --git a/site/src/pages/OrganizationSettingsPage/ProvisionersPage/ProvisionersPage.tsx b/site/src/pages/OrganizationSettingsPage/ProvisionersPage/ProvisionersPage.tsx index 871eb7b91fa0f..051f916c3ad99 100644 --- a/site/src/pages/OrganizationSettingsPage/ProvisionersPage/ProvisionersPage.tsx +++ b/site/src/pages/OrganizationSettingsPage/ProvisionersPage/ProvisionersPage.tsx @@ -9,13 +9,13 @@ import { ProvisionerDaemonsPage } from "./ProvisionerDaemonsPage"; import { ProvisionerJobsPage } from "./ProvisionerJobsPage"; const ProvisionersPage: FC = () => { - const { organization } = useOrganizationSettings(); + const { organization, organizationPermissions } = useOrganizationSettings(); const tab = useSearchParamsKey({ key: "tab", defaultValue: "jobs", }); - if (!organization) { + if (!organization || !organizationPermissions?.viewProvisionerJobs) { return ( <> <Helmet> diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 7ef77e73579b5..74d4de9121e2e 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -2855,6 +2855,7 @@ export const MockOrganizationPermissions: OrganizationPermissions = { createOrgRoles: true, assignOrgRoles: true, viewProvisioners: true, + viewProvisionerJobs: true, viewIdpSyncSettings: true, editIdpSyncSettings: true, }; @@ -2870,6 +2871,7 @@ export const MockNoOrganizationPermissions: OrganizationPermissions = { createOrgRoles: false, assignOrgRoles: false, viewProvisioners: false, + viewProvisionerJobs: false, viewIdpSyncSettings: false, editIdpSyncSettings: false, }; From 81fbdf4116ff9aa219efeea1543f0fabf665b677 Mon Sep 17 00:00:00 2001 From: McKayla Washburn <mckayla@hey.com> Date: Tue, 18 Feb 2025 23:19:35 +0000 Subject: [PATCH 15/21] mmmmm bep --- .../management/OrganizationSidebarView.tsx | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/site/src/modules/management/OrganizationSidebarView.tsx b/site/src/modules/management/OrganizationSidebarView.tsx index a875579ceab67..b5b1ae09122ec 100644 --- a/site/src/modules/management/OrganizationSidebarView.tsx +++ b/site/src/modules/management/OrganizationSidebarView.tsx @@ -191,13 +191,14 @@ const OrganizationSettingsNavigation: FC< Roles </SettingsSidebarNavItem> )} - {orgPermissions.viewProvisionerJobs && ( - <SettingsSidebarNavItem - href={urlForSubpage(organization.name, "provisioners")} - > - Provisioners - </SettingsSidebarNavItem> - )} + {orgPermissions.viewProvisioners && + orgPermissions.viewProvisionerJobs && ( + <SettingsSidebarNavItem + href={urlForSubpage(organization.name, "provisioners")} + > + Provisioners + </SettingsSidebarNavItem> + )} {orgPermissions.viewIdpSyncSettings && ( <SettingsSidebarNavItem href={urlForSubpage(organization.name, "idp-sync")} From 41ff221676dc8c1d6c6b0804c239fb81a6b741e6 Mon Sep 17 00:00:00 2001 From: McKayla Washburn <mckayla@hey.com> Date: Tue, 18 Feb 2025 23:21:23 +0000 Subject: [PATCH 16/21] :| --- site/src/modules/management/OrganizationSettingsLayout.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/site/src/modules/management/OrganizationSettingsLayout.tsx b/site/src/modules/management/OrganizationSettingsLayout.tsx index 906a8aaf42ff5..ffca37beddef2 100644 --- a/site/src/modules/management/OrganizationSettingsLayout.tsx +++ b/site/src/modules/management/OrganizationSettingsLayout.tsx @@ -10,17 +10,17 @@ import { BreadcrumbSeparator, } from "components/Breadcrumb/Breadcrumb"; import { Loader } from "components/Loader/Loader"; +import { Paywall } from "components/Paywall/Paywall"; import { useDashboard } from "modules/dashboard/useDashboard"; import NotFoundPage from "pages/404Page/404Page"; import { type FC, Suspense, createContext, useContext } from "react"; import { useQuery } from "react-query"; import { Outlet, useParams } from "react-router-dom"; +import { docs } from "utils/docs"; import { type OrganizationPermissions, canViewOrganization, } from "./organizationPermissions"; -import { Paywall } from "components/Paywall/Paywall"; -import { docs } from "utils/docs"; export const OrganizationSettingsContext = createContext< OrganizationSettingsValue | undefined From 58a08768ce7174a465f2767298cc98eabe468917 Mon Sep 17 00:00:00 2001 From: McKayla Washburn <mckayla@hey.com> Date: Tue, 18 Feb 2025 23:33:38 +0000 Subject: [PATCH 17/21] testin' --- coderd/rbac/roles_test.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/coderd/rbac/roles_test.go b/coderd/rbac/roles_test.go index db0d9832579fc..cb43b1b1751d6 100644 --- a/coderd/rbac/roles_test.go +++ b/coderd/rbac/roles_test.go @@ -117,6 +117,7 @@ func TestRolePermissions(t *testing.T) { owner := authSubject{Name: "owner", Actor: rbac.Subject{ID: adminID.String(), Roles: rbac.RoleIdentifiers{rbac.RoleMember(), rbac.RoleOwner()}}} templateAdmin := authSubject{Name: "template-admin", Actor: rbac.Subject{ID: templateAdminID.String(), Roles: rbac.RoleIdentifiers{rbac.RoleMember(), rbac.RoleTemplateAdmin()}}} userAdmin := authSubject{Name: "user-admin", Actor: rbac.Subject{ID: userAdminID.String(), Roles: rbac.RoleIdentifiers{rbac.RoleMember(), rbac.RoleUserAdmin()}}} + auditor := authSubject{Name: "auditor", Actor: rbac.Subject{ID: auditorID.String(), Roles: rbac.RoleIdentifiers{rbac.RoleMember(), rbac.RoleAuditor()}}} orgAdmin := authSubject{Name: "org_admin", Actor: rbac.Subject{ID: adminID.String(), Roles: rbac.RoleIdentifiers{rbac.RoleMember(), rbac.ScopedRoleOrgMember(orgID), rbac.ScopedRoleOrgAdmin(orgID)}}} orgAuditor := authSubject{Name: "org_auditor", Actor: rbac.Subject{ID: auditorID.String(), Roles: rbac.RoleIdentifiers{rbac.RoleMember(), rbac.ScopedRoleOrgMember(orgID), rbac.ScopedRoleOrgAuditor(orgID)}}} @@ -286,8 +287,8 @@ func TestRolePermissions(t *testing.T) { Actions: []policy.Action{policy.ActionRead}, Resource: rbac.ResourceOrganization.WithID(orgID).InOrg(orgID), AuthorizeMap: map[bool][]hasAuthSubjects{ - true: {owner, orgAdmin, orgMemberMe, templateAdmin, orgTemplateAdmin, orgAuditor, orgUserAdmin}, - false: {setOtherOrg, memberMe, userAdmin}, + true: {owner, orgAdmin, orgMemberMe, templateAdmin, orgTemplateAdmin, auditor, orgAuditor, userAdmin, orgUserAdmin}, + false: {setOtherOrg, memberMe}, }, }, { From 71b317074004155ce4238c7b6a926e2fb9bca144 Mon Sep 17 00:00:00 2001 From: McKayla Washburn <mckayla@hey.com> Date: Tue, 18 Feb 2025 23:56:22 +0000 Subject: [PATCH 18/21] ok lets do this separately --- .../management/OrganizationSettingsLayout.tsx | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/site/src/modules/management/OrganizationSettingsLayout.tsx b/site/src/modules/management/OrganizationSettingsLayout.tsx index ffca37beddef2..ae1ce597641ae 100644 --- a/site/src/modules/management/OrganizationSettingsLayout.tsx +++ b/site/src/modules/management/OrganizationSettingsLayout.tsx @@ -10,13 +10,11 @@ import { BreadcrumbSeparator, } from "components/Breadcrumb/Breadcrumb"; import { Loader } from "components/Loader/Loader"; -import { Paywall } from "components/Paywall/Paywall"; import { useDashboard } from "modules/dashboard/useDashboard"; import NotFoundPage from "pages/404Page/404Page"; import { type FC, Suspense, createContext, useContext } from "react"; import { useQuery } from "react-query"; import { Outlet, useParams } from "react-router-dom"; -import { docs } from "utils/docs"; import { type OrganizationPermissions, canViewOrganization, @@ -125,15 +123,7 @@ const OrganizationSettingsLayout: FC = () => { <hr className="h-px border-none bg-border" /> <div className="px-10 max-w-screen-2xl"> <Suspense fallback={<Loader />}> - {showOrganizations ? ( - <Outlet /> - ) : ( - <Paywall - message="Organizations" - description="Organizations can be used to segment and isolate resources inside a Coder deployment. You need a Premium license to use this feature." - documentationLink={docs("/admin/users/organizations")} - /> - )} + <Outlet /> </Suspense> </div> </div> From 37d87d3790e5aaa8bed4d87a11c619e510767b9f Mon Sep 17 00:00:00 2001 From: McKayla Washburn <mckayla@hey.com> Date: Wed, 19 Feb 2025 00:37:33 +0000 Subject: [PATCH 19/21] add missing query to terminal page story --- site/src/api/queries/organizations.ts | 7 ++++++- site/src/pages/TerminalPage/TerminalPage.stories.tsx | 2 ++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/site/src/api/queries/organizations.ts b/site/src/api/queries/organizations.ts index 58fb9230c9396..a27514a03c161 100644 --- a/site/src/api/queries/organizations.ts +++ b/site/src/api/queries/organizations.ts @@ -266,9 +266,14 @@ export const organizationsPermissions = ( }; }; +export const anyOrganizationPermissionsKey = [ + "authorization", + "anyOrganization", +]; + export const anyOrganizationPermissions = () => { return { - queryKey: ["authorization", "anyOrganization"], + queryKey: anyOrganizationPermissionsKey, queryFn: () => API.checkAuthorization({ checks: anyOrganizationPermissionChecks, diff --git a/site/src/pages/TerminalPage/TerminalPage.stories.tsx b/site/src/pages/TerminalPage/TerminalPage.stories.tsx index 4fae86ff8b8ca..ded6047122932 100644 --- a/site/src/pages/TerminalPage/TerminalPage.stories.tsx +++ b/site/src/pages/TerminalPage/TerminalPage.stories.tsx @@ -22,6 +22,7 @@ import { } from "testHelpers/entities"; import { withWebSocket } from "testHelpers/storybook"; import TerminalPage from "./TerminalPage"; +import { anyOrganizationPermissionsKey } from "api/queries/organizations"; const createWorkspaceWithAgent = (lifecycle: WorkspaceAgentLifecycle) => { return { @@ -76,6 +77,7 @@ const meta = { key: getAuthorizationKey({ checks: permissionsToCheck }), data: { editWorkspaceProxies: true }, }, + { key: anyOrganizationPermissionsKey, data: {} }, ], chromatic: { delay: 300 }, }, From 660cbda086ca16001cdffac6e19d3d97aac77982 Mon Sep 17 00:00:00 2001 From: McKayla Washburn <mckayla@hey.com> Date: Wed, 19 Feb 2025 00:45:11 +0000 Subject: [PATCH 20/21] =?UTF-8?q?=F0=9F=A7=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- site/src/pages/TerminalPage/TerminalPage.stories.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site/src/pages/TerminalPage/TerminalPage.stories.tsx b/site/src/pages/TerminalPage/TerminalPage.stories.tsx index ded6047122932..b9dfeba1d811d 100644 --- a/site/src/pages/TerminalPage/TerminalPage.stories.tsx +++ b/site/src/pages/TerminalPage/TerminalPage.stories.tsx @@ -1,5 +1,6 @@ import type { Meta, StoryObj } from "@storybook/react"; import { getAuthorizationKey } from "api/queries/authCheck"; +import { anyOrganizationPermissionsKey } from "api/queries/organizations"; import { workspaceByOwnerAndNameKey } from "api/queries/workspaces"; import type { Workspace, WorkspaceAgentLifecycle } from "api/typesGenerated"; import { AuthProvider } from "contexts/auth/AuthProvider"; @@ -22,7 +23,6 @@ import { } from "testHelpers/entities"; import { withWebSocket } from "testHelpers/storybook"; import TerminalPage from "./TerminalPage"; -import { anyOrganizationPermissionsKey } from "api/queries/organizations"; const createWorkspaceWithAgent = (lifecycle: WorkspaceAgentLifecycle) => { return { From f0f3859f0a06e48b591d8c67b760dce0b70b560f Mon Sep 17 00:00:00 2001 From: McKayla Washburn <mckayla@hey.com> Date: Wed, 19 Feb 2025 18:36:44 +0000 Subject: [PATCH 21/21] story fixes --- .../OrganizationSidebarView.stories.tsx | 32 ++++++++- .../management/OrganizationSidebarView.tsx | 66 ++++++++----------- 2 files changed, 58 insertions(+), 40 deletions(-) diff --git a/site/src/modules/management/OrganizationSidebarView.stories.tsx b/site/src/modules/management/OrganizationSidebarView.stories.tsx index ae0402d5b8758..0a3ebef493239 100644 --- a/site/src/modules/management/OrganizationSidebarView.stories.tsx +++ b/site/src/modules/management/OrganizationSidebarView.stories.tsx @@ -118,6 +118,36 @@ export const OverflowDropdown: Story = { }, }; +export const NoOrganizations: Story = { + args: { + organizations: [], + activeOrganization: undefined, + orgPermissions: MockNoOrganizationPermissions, + permissions: MockNoPermissions, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click( + canvas.getByRole("button", { name: /No organization selected/i }), + ); + }, +}; + +export const NoOtherOrganizations: Story = { + args: { + organizations: [MockOrganization], + activeOrganization: MockOrganization, + orgPermissions: MockNoOrganizationPermissions, + permissions: MockNoPermissions, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click( + canvas.getByRole("button", { name: /My Organization/i }), + ); + }, +}; + export const NoPermissions: Story = { args: { activeOrganization: MockOrganization, @@ -263,7 +293,7 @@ export const SearchForOrg: Story = { // dropdown is not in #storybook-root so must query full document const globalScreen = within(document.body); const searchInput = - await globalScreen.getByPlaceholderText("Find organization"); + await globalScreen.findByPlaceholderText("Find organization"); await userEvent.type(searchInput, "ALPHA"); diff --git a/site/src/modules/management/OrganizationSidebarView.tsx b/site/src/modules/management/OrganizationSidebarView.tsx index 2c05a8d1663bc..7f3b697766563 100644 --- a/site/src/modules/management/OrganizationSidebarView.tsx +++ b/site/src/modules/management/OrganizationSidebarView.tsx @@ -89,45 +89,33 @@ export const OrganizationSidebarView: FC< <CommandList> <CommandEmpty>No organization found.</CommandEmpty> <CommandGroup className="pb-2"> - {sortedOrganizations.length > (activeOrganization ? 1 : 0) ? ( - <div className="flex flex-col max-h-[260px] overflow-y-auto"> - {sortedOrganizations.map((organization) => ( - <CommandItem - key={organization.id} - value={`${organization.display_name} ${organization.name}`} - onSelect={() => { - setIsPopoverOpen(false); - navigate(urlForSubpage(organization.name)); - }} - // There is currently an issue with the cmdk component for keyboard navigation - // https://github.com/pacocoursey/cmdk/issues/322 - tabIndex={0} - > - <Avatar - size="sm" - src={organization.icon} - fallback={organization.display_name} - /> - <span className="truncate"> - {organization?.display_name || organization?.name} - </span> - {activeOrganization?.name === organization.name && ( - <Check - size={16} - strokeWidth={2} - className="ml-auto" - /> - )} - </CommandItem> - ))} - </div> - ) : ( - !permissions.createOrganization && ( - <span className="select-none text-content-disabled text-center rounded-sm px-2 py-2 text-sm font-medium"> - No more organizations - </span> - ) - )} + <div className="flex flex-col max-h-[260px] overflow-y-auto"> + {sortedOrganizations.map((organization) => ( + <CommandItem + key={organization.id} + value={`${organization.display_name} ${organization.name}`} + onSelect={() => { + setIsPopoverOpen(false); + navigate(urlForSubpage(organization.name)); + }} + // There is currently an issue with the cmdk component for keyboard navigation + // https://github.com/pacocoursey/cmdk/issues/322 + tabIndex={0} + > + <Avatar + size="sm" + src={organization.icon} + fallback={organization.display_name} + /> + <span className="truncate"> + {organization?.display_name || organization?.name} + </span> + {activeOrganization?.name === organization.name && ( + <Check size={16} strokeWidth={2} className="ml-auto" /> + )} + </CommandItem> + ))} + </div> </CommandGroup> {permissions.createOrganization && ( <>