diff --git a/coderd/rbac/roles.go b/coderd/rbac/roles.go index 7fb141e557e96..e1399aded95d0 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{}, @@ -325,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{}, @@ -348,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/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}, }, }, { diff --git a/site/src/api/queries/organizations.ts b/site/src/api/queries/organizations.ts index 70cd57628f578..a27514a03c161 100644 --- a/site/src/api/queries/organizations.ts +++ b/site/src/api/queries/organizations.ts @@ -1,11 +1,17 @@ import { API } from "api/api"; import type { - AuthorizationResponse, CreateOrganizationRequest, GroupSyncSettings, RoleSyncSettings, UpdateOrganizationRequest, } from "api/typesGenerated"; +import { + type AnyOrganizationPermissions, + type OrganizationPermissionName, + type OrganizationPermissions, + anyOrganizationPermissionChecks, + organizationPermissionChecks, +} from "modules/management/organizationPermissions"; import type { QueryClient } from "react-query"; import { meKey } from "./users"; @@ -197,53 +203,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", - }, - }, - }), - }; -}; - export const provisionerJobQueryKey = (orgId: string) => [ "organization", orgId, @@ -276,58 +235,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({ @@ -343,15 +257,30 @@ 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; }, }; }; +export const anyOrganizationPermissionsKey = [ + "authorization", + "anyOrganization", +]; + +export const anyOrganizationPermissions = () => { + return { + queryKey: anyOrganizationPermissionsKey, + queryFn: () => + API.checkAuthorization({ + checks: anyOrganizationPermissionChecks, + }) as Promise, + }; +}; + export const getOrganizationIdpSyncClaimFieldValuesKey = ( organization: string, field: string, diff --git a/site/src/contexts/auth/permissions.tsx b/site/src/contexts/auth/permissions.tsx index b44d85e963fe4..1043862942edb 100644 --- a/site/src/contexts/auth/permissions.tsx +++ b/site/src/contexts/auth/permissions.tsx @@ -16,7 +16,6 @@ export const checks = { readWorkspaceProxies: "readWorkspaceProxies", editWorkspaceProxies: "editWorkspaceProxies", createOrganization: "createOrganization", - editAnyOrganization: "editAnyOrganization", viewAnyGroup: "viewAnyGroup", createGroup: "createGroup", viewAllLicenses: "viewAllLicenses", @@ -122,13 +121,6 @@ export const permissionsToCheck = { }, action: "create", }, - [checks.editAnyOrganization]: { - object: { - resource_type: "organization", - any_org: true, - }, - action: "update", - }, [checks.viewAnyGroup]: { object: { resource_type: "group", diff --git a/site/src/modules/dashboard/DashboardProvider.tsx b/site/src/modules/dashboard/DashboardProvider.tsx index d8fa339deccbb..bf8e307206aea 100644 --- a/site/src/modules/dashboard/DashboardProvider.tsx +++ b/site/src/modules/dashboard/DashboardProvider.tsx @@ -1,7 +1,10 @@ import { appearance } from "api/queries/appearance"; import { entitlements } from "api/queries/entitlements"; import { experiments } from "api/queries/experiments"; -import { organizations } from "api/queries/organizations"; +import { + anyOrganizationPermissions, + organizations, +} from "api/queries/organizations"; import type { AppearanceConfig, Entitlements, @@ -11,6 +14,7 @@ import type { import { ErrorAlert } from "components/Alert/ErrorAlert"; import { Loader } from "components/Loader/Loader"; import { useEmbeddedMetadata } from "hooks/useEmbeddedMetadata"; +import { canViewAnyOrganization } from "modules/management/organizationPermissions"; import { type FC, type PropsWithChildren, createContext } from "react"; import { useQuery } from "react-query"; import { selectFeatureVisibility } from "./entitlements"; @@ -21,6 +25,7 @@ export interface DashboardValue { appearance: AppearanceConfig; organizations: readonly Organization[]; showOrganizations: boolean; + canViewOrganizationSettings: boolean; } export const DashboardContext = createContext( @@ -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 fa249f3a7f004..f80887e1f1aec 100644 --- a/site/src/modules/dashboard/Navbar/Navbar.tsx +++ b/site/src/modules/dashboard/Navbar/Navbar.tsx @@ -12,14 +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 && Boolean(permissions.viewAnyAuditLog); - const canViewDeployment = Boolean(permissions.viewDeploymentValues); - const canViewOrganizations = - Boolean(permissions.editAnyOrganization) && showOrganizations; + featureVisibility.audit_log && permissions.viewAnyAuditLog; + const canViewDeployment = permissions.viewDeploymentValues; + const canViewOrganizations = canViewOrganizationSettings; const proxyContextValue = useProxy(); const canViewHealth = canViewDeployment; 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 = ({ - {Boolean(buildInfo?.deployment_id) && ( + {buildInfo?.deployment_id && (
= ({ text-overflow: ellipsis; `} > - {buildInfo?.deployment_id} + {buildInfo.deployment_id}
; organization?: Organization; + organizationPermissions?: OrganizationPermissions; }>; export const useOrganizationSettings = (): OrganizationSettingsValue => { @@ -36,81 +45,89 @@ export const useOrganizationSettings = (): OrganizationSettingsValue => { return context; }; -/** - * Return true if the user can edit the organization settings or its members. - */ -export const canEditOrganization = ( - permissions: AuthorizationResponse | undefined, -) => { - return ( - permissions !== undefined && - (permissions.editOrganization || - permissions.editMembers || - permissions.editGroups) - ); -}; - const OrganizationSettingsLayout: FC = () => { - const { permissions } = useAuthenticated(); - const { organizations } = useDashboard(); + const { organizations, showOrganizations } = useDashboard(); const { organization: orgName } = useParams() as { organization?: string; }; - const canViewOrganizationSettingsPage = - permissions.viewDeploymentValues || permissions.editAnyOrganization; - const organization = orgName ? organizations.find((org) => org.name === orgName) : undefined; + const orgPermissionsQuery = useQuery( + organizationsPermissions(organizations?.map((o) => o.id)), + ); + + if (orgPermissionsQuery.isError) { + 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 ( - - -
- - - - Admin Settings - - - - - Organizations - - - {organization && ( - <> - - - - - {organization?.name} - - - - )} - - -
-
- }> - - -
+ +
+ + + + Admin Settings + + + + + Organizations + + + {organization && ( + <> + + + + + {organization.display_name} + + + + )} + + +
+
+ }> + +
- - +
+
); }; diff --git a/site/src/modules/management/OrganizationSidebar.tsx b/site/src/modules/management/OrganizationSidebar.tsx index 8ef14f9baf165..3b6451b0252bc 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 { Sidebar as BaseSidebar } from "components/Sidebar/Sidebar"; 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"; /** - * 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 6533a5e004ef5..0a3ebef493239 100644 --- a/site/src/modules/management/OrganizationSidebarView.stories.tsx +++ b/site/src/modules/management/OrganizationSidebarView.stories.tsx @@ -1,17 +1,16 @@ import type { Meta, StoryObj } from "@storybook/react"; import { expect, userEvent, waitFor, within } from "@storybook/test"; -import type { AuthorizationResponse } from "api/typesGenerated"; +import type { Organization } from "api/typesGenerated"; import { + MockNoOrganizationPermissions, MockNoPermissions, MockOrganization, MockOrganization2, + MockOrganizationPermissions, MockPermissions, } from "testHelpers/entities"; import { withDashboardProvider } from "testHelpers/storybook"; -import { - OrganizationSidebarView, - type OrganizationWithPermissions, -} from "./OrganizationSidebarView"; +import { OrganizationSidebarView } from "./OrganizationSidebarView"; const meta: Meta = { title: "modules/management/OrganizationSidebarView", @@ -20,26 +19,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, }, }; @@ -47,18 +27,10 @@ const meta: Meta = { export default meta; type Story = StoryObj; -export const LoadingOrganizations: Story = { - args: { - organizations: undefined, - }, -}; - export const NoCreateOrg: Story = { args: { - activeOrganization: { - ...MockOrganization, - permissions: { createOrganization: false }, - }, + activeOrganization: MockOrganization, + orgPermissions: MockNoOrganizationPermissions, permissions: { ...MockPermissions, createOrganization: false, @@ -77,23 +49,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", @@ -103,7 +67,6 @@ export const OverflowDropdown: Story = { created_at: "", updated_at: "", is_default: false, - permissions: {}, }, { id: "my-organization-4-id", @@ -114,7 +77,6 @@ export const OverflowDropdown: Story = { created_at: "", updated_at: "", is_default: false, - permissions: {}, }, { id: "my-organization-5-id", @@ -125,7 +87,6 @@ export const OverflowDropdown: Story = { created_at: "", updated_at: "", is_default: false, - permissions: {}, }, { id: "my-organization-6-id", @@ -136,7 +97,6 @@ export const OverflowDropdown: Story = { created_at: "", updated_at: "", is_default: false, - permissions: {}, }, { id: "my-organization-7-id", @@ -147,7 +107,6 @@ export const OverflowDropdown: Story = { created_at: "", updated_at: "", is_default: false, - permissions: {}, }, ], }, @@ -159,129 +118,88 @@ 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, - 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, + viewMembers: true, + viewGroups: true, + viewOrgRoles: true, + viewProvisioners: true, + viewIdpSyncSettings: true, }, permissions: { ...MockPermissions, createOrganization: false, }, - organizations: [ - { - ...MockOrganization, - permissions: { - editOrganization: false, - editMembers: true, - editGroups: true, - auditOrganization: false, - }, - }, - ], + organizations: [MockOrganization], }, }; @@ -291,26 +209,17 @@ export const OrgsDisabled: Story = { }, }; -const commonPerms: AuthorizationResponse = { - editOrganization: true, - editMembers: true, - editGroups: true, - auditOrganization: true, -}; - -const activeOrganization: OrganizationWithPermissions = { +const activeOrganization: Organization = { ...MockOrganization, display_name: "Omega org", name: "omega", id: "1", - permissions: { - ...commonPerms, - }, }; export const OrgsSortedAlphabetically: Story = { args: { activeOrganization, + orgPermissions: MockOrganizationPermissions, permissions: { ...MockPermissions, createOrganization: true, @@ -321,14 +230,12 @@ export const OrgsSortedAlphabetically: Story = { display_name: "Zeta Org", id: "2", name: "zeta", - permissions: commonPerms, }, { ...MockOrganization, display_name: "alpha Org", id: "3", name: "alpha", - permissions: commonPerms, }, activeOrganization, ], @@ -369,14 +276,12 @@ export const SearchForOrg: Story = { display_name: "Zeta Org", id: "2", name: "zeta", - permissions: commonPerms, }, { ...MockOrganization, display_name: "alpha Org", id: "3", name: "fish", - permissions: commonPerms, }, activeOrganization, ], @@ -388,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 b618c4f72bd3d..7f3b697766563 100644 --- a/site/src/modules/management/OrganizationSidebarView.tsx +++ b/site/src/modules/management/OrganizationSidebarView.tsx @@ -1,4 +1,4 @@ -import type { AuthorizationResponse, Organization } from "api/typesGenerated"; +import type { Organization } from "api/typesGenerated"; import { Avatar } from "components/Avatar/Avatar"; import { Button } from "components/Button/Button"; import { @@ -10,69 +10,25 @@ import { CommandList, CommandSeparator, } 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; -} - -interface SidebarProps { - /** The active org name, if any. Overrides activeSettings. */ - activeOrganization: OrganizationWithPermissions | undefined; - /** Organizations and their permissions or undefined if still fetching. */ - organizations: OrganizationWithPermissions[] | undefined; - /** Site-wide permissions. */ - permissions: Permissions; -} - -/** - * Organization settings left sidebar menu. - */ -export const OrganizationSidebarView: FC = ({ - activeOrganization, - organizations, - permissions, -}) => { - const { showOrganizations } = useDashboard(); - - return ( - - {showOrganizations && ( - - )} - - ); -}; - -function urlForSubpage(organizationName: string, subpage = ""): string { - return [`/organizations/${organizationName}`, subpage] - .filter(Boolean) - .join("/"); -} +import type { OrganizationPermissions } from "./organizationPermissions"; interface OrganizationsSettingsNavigationProps { - /** The active org name if an org is being viewed. */ - activeOrganization: OrganizationWithPermissions | undefined; + /** The organization selected from the dropdown */ + 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; } @@ -83,18 +39,13 @@ interface OrganizationsSettingsNavigationProps { * * If organizations or their permissions are still loading, show a loader. */ -const OrganizationsSettingsNavigation: FC< +export const OrganizationSidebarView: FC< OrganizationsSettingsNavigationProps -> = ({ activeOrganization, organizations, permissions }) => { - // Wait for organizations and their permissions to load - if (!organizations || !activeOrganization) { - return ; - } - +> = ({ activeOrganization, orgPermissions, organizations, permissions }) => { const sortedOrganizations = [...organizations].sort((a, b) => { // active org first - if (a.id === activeOrganization.id) return -1; - if (b.id === activeOrganization.id) return 1; + if (a.id === activeOrganization?.id) return -1; + if (b.id === activeOrganization?.id) return 1; return a.display_name .toLowerCase() @@ -114,16 +65,20 @@ const OrganizationsSettingsNavigation: 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} -
@@ -134,39 +89,33 @@ const OrganizationsSettingsNavigation: FC< No organization found. - {sortedOrganizations.length > 1 && ( -
- {sortedOrganizations.map((organization) => ( - { - 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} - > - - - {organization?.display_name || organization?.name} - - {activeOrganization.name === organization.name && ( - - )} - - ))} -
- )} +
+ {sortedOrganizations.map((organization) => ( + { + 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} + > + + + {organization?.display_name || organization?.name} + + {activeOrganization?.name === organization.name && ( + + )} + + ))} +
{permissions.createOrganization && ( <> @@ -190,58 +139,69 @@ 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: OrganizationPermissions; } const OrganizationSettingsNavigation: FC< OrganizationSettingsNavigationProps -> = ({ organization }) => { +> = ({ organization, orgPermissions }) => { return ( <>
- {organization.permissions.editMembers && ( + {orgPermissions.viewMembers && ( Members )} - {organization.permissions.editGroups && ( + {orgPermissions.viewGroups && ( Groups )} - {organization.permissions.assignOrgRole && ( + {orgPermissions.viewOrgRoles && ( Roles )} - {organization.permissions.viewProvisioners && ( - - Provisioners - - )} - {organization.permissions.viewIdpSyncSettings && ( + {orgPermissions.viewProvisioners && + orgPermissions.viewProvisionerJobs && ( + + Provisioners + + )} + {orgPermissions.viewIdpSyncSettings && ( IdP Sync )} - {organization.permissions.editOrganization && ( + {orgPermissions.editSettings && ( diff --git a/site/src/modules/management/organizationPermissions.tsx b/site/src/modules/management/organizationPermissions.tsx new file mode 100644 index 0000000000000..2a414856105a4 --- /dev/null +++ b/site/src/modules/management/organizationPermissions.tsx @@ -0,0 +1,200 @@ +import type { AuthorizationCheck } from "api/typesGenerated"; + +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", + }, + editSettings: { + 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", + }, + viewProvisionerJobs: { + object: { + resource_type: "provisioner_jobs", + organization_id: organizationId, + }, + action: "read", + }, + viewIdpSyncSettings: { + object: { + resource_type: "idpsync_settings", + organization_id: organizationId, + }, + action: "read", + }, + editIdpSyncSettings: { + object: { + resource_type: "idpsync_settings", + organization_id: organizationId, + }, + 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 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.editSettings || + permissions.assignOrgRoles || + permissions.editIdpSyncSettings || + permissions.createOrgRoles) + ); +}; + +export type AnyOrganizationPermissions = { + [k in AnyOrganizationPermissionName]: boolean; +}; + +export type AnyOrganizationPermissionName = + keyof typeof anyOrganizationPermissionChecks; + +export const anyOrganizationPermissionChecks = { + viewAnyMembers: { + object: { + resource_type: "organization_member", + any_org: true, + }, + action: "read", + }, + editAnyGroups: { + object: { + resource_type: "group", + any_org: true, + }, + action: "update", + }, + assignAnyRoles: { + object: { + resource_type: "assign_org_role", + any_org: true, + }, + action: "assign", + }, + viewAnyIdpSyncSettings: { + object: { + resource_type: "idpsync_settings", + any_org: true, + }, + action: "read", + }, + editAnySettings: { + object: { + resource_type: "organization", + any_org: true, + }, + 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.viewAnyIdpSyncSettings || + permissions.editAnySettings) + ); +}; 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..a99ec44334530 100644 --- a/site/src/pages/GroupsPage/GroupsPage.tsx +++ b/site/src/pages/GroupsPage/GroupsPage.tsx @@ -1,7 +1,8 @@ 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 { ErrorAlert } from "components/Alert/ErrorAlert"; import { Button } from "components/Button/Button"; import { EmptyState } from "components/EmptyState/EmptyState"; import { displayError } from "components/GlobalSnackbar/utils"; @@ -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..b9adbb44feb26 100644 --- a/site/src/pages/OrganizationSettingsPage/CustomRolesPage/CreateEditRolePage.tsx +++ b/site/src/pages/OrganizationSettingsPage/CustomRolesPage/CreateEditRolePage.tsx @@ -1,11 +1,11 @@ import { getErrorMessage } from "api/errors"; -import { organizationPermissions } from "api/queries/organizations"; import { createOrganizationRole, organizationRoles, 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"; @@ -24,9 +24,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,12 +35,15 @@ export const CreateEditRolePage: FC = () => { organizationRoles(organizationName), ); const role = roleData?.find((role) => role.name === roleName); - const permissions = permissionsQuery.data; - if (isLoading || !permissions) { + if (isLoading) { return ; } + if (!organizationPermissions) { + return ; + } + return ( <> @@ -80,7 +81,7 @@ export const CreateEditRolePage: FC = () => { : createOrganizationRoleMutation.isLoading } organizationName={organizationName} - canAssignOrgRole={permissions.assignOrgRole} + canAssignOrgRole={organizationPermissions.assignOrgRoles} /> ); diff --git a/site/src/pages/OrganizationSettingsPage/CustomRolesPage/CustomRolesPage.tsx b/site/src/pages/OrganizationSettingsPage/CustomRolesPage/CustomRolesPage.tsx index 905e67ebd26e3..362448368d1a6 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,8 @@ export const CustomRolesPage: FC = () => { builtInRoles={builtInRoles} customRoles={customRoles} onDeleteRole={setRoleToDelete} - canAssignOrgRole={permissions.assignOrgRole} + canAssignOrgRole={organizationPermissions.assignOrgRoles} + canCreateOrgRole={organizationPermissions.createOrgRoles} isCustomRolesEnabled={isCustomRolesEnabled} /> 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 = { title: "pages/OrganizationCustomRolesPage", component: CustomRolesPageView, + args: { + builtInRoles: [MockRoleWithOrgPermissions], + customRoles: [MockRoleWithOrgPermissions], + canAssignOrgRole: true, + canCreateOrgRole: true, + isCustomRolesEnabled: true, + }, }; export default meta; type Story = StoryObj; +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/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 = ({ customRoles, onDeleteRole, canAssignOrgRole, + canCreateOrgRole, isCustomRolesEnabled, }) => { return ( @@ -66,7 +68,7 @@ export const CustomRolesPageView: FC = ({ permissions. - {canAssignOrgRole && isCustomRolesEnabled && ( + {canCreateOrgRole && isCustomRolesEnabled && ( 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 ac90365ea4d43..078ae1a0cbba8 100644 --- a/site/src/pages/OrganizationSettingsPage/OrganizationMembersPage.tsx +++ b/site/src/pages/OrganizationSettingsPage/OrganizationMembersPage.tsx @@ -4,15 +4,14 @@ import { groupsByUserIdInOrganization } from "api/queries/groups"; import { addOrganizationMember, organizationMembers, - organizationPermissions, removeOrganizationMember, updateOrganizationMemberRoles, } from "api/queries/organizations"; 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"; import { useAuthenticated } from "contexts/auth/RequireAuth"; import { useOrganizationSettings } from "modules/management/OrganizationSettingsLayout"; @@ -25,18 +24,18 @@ import { OrganizationMembersPageView } from "./OrganizationMembersPageView"; 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,19 +51,14 @@ 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) { - return ; + if (!organization || !organizationPermissions) { + return ; } - const helmet = organization && ( + const helmet = ( {pageTitle("Members", organization.display_name || organization.name)} @@ -77,9 +71,11 @@ const OrganizationMembersPage: FC = () => { {helmet} <OrganizationMembersPageView allAvailableRoles={organizationRolesQuery.data} - canEditMembers={permissions.editMembers} + canEditMembers={organizationPermissions.editMembers} error={ membersQuery.error ?? + organizationRolesQuery.error ?? + groupsByUserIdQuery.error ?? addMemberMutation.error ?? removeMemberMutation.error ?? updateMemberRolesMutation.error 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> 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/OrganizationRedirect.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationRedirect.tsx new file mode 100644 index 0000000000000..b862ad41dc883 --- /dev/null +++ b/site/src/pages/OrganizationSettingsPage/OrganizationRedirect.tsx @@ -0,0 +1,30 @@ +import { EmptyState } from "components/EmptyState/EmptyState"; +import { useOrganizationSettings } from "modules/management/OrganizationSettingsLayout"; +import { canEditOrganization } from "modules/management/organizationPermissions"; +import type { FC } from "react"; +import { Navigate } from "react-router-dom"; + +const OrganizationRedirect: FC = () => { + const { + organizations, + organizationPermissionsByOrganizationId: organizationPermissions, + } = useOrganizationSettings(); + + // 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(organizationPermissions[org.id])); + if (editableOrg) { + return <Navigate to={`/organizations/${editableOrg.name}`} replace />; + } + // If they cannot edit any org, just redirect to an org they can read. + if (organizations.length > 0) { + return <Navigate to={`/organizations/${organizations[0].name}`} replace />; + } + return <EmptyState message="No organizations found" />; +}; + +export default OrganizationRedirect; diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationSettingsPage.stories.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationSettingsPage.stories.tsx deleted file mode 100644 index f6b6b49c88d37..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, - withManagementSettingsProvider, -} from "testHelpers/storybook"; -import OrganizationSettingsPage from "./OrganizationSettingsPage"; - -const meta: Meta<typeof OrganizationSettingsPage> = { - title: "pages/OrganizationSettingsPage", - component: OrganizationSettingsPage, - decorators: [ - withAuthProvider, - withDashboardProvider, - withManagementSettingsProvider, - ], - 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, - }, - }, - }, - ], - }, -}; diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationSettingsPage.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationSettingsPage.tsx index 698f2ee75822f..13c339dcc3c09 100644 --- a/site/src/pages/OrganizationSettingsPage/OrganizationSettingsPage.tsx +++ b/site/src/pages/OrganizationSettingsPage/OrganizationSettingsPage.tsx @@ -1,30 +1,20 @@ import { deleteOrganization, - organizationsPermissions, updateOrganization, } from "api/queries/organizations"; -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 { useMutation, useQueryClient } from "react-query"; +import { useNavigate } from "react-router-dom"; import { OrganizationSettingsPageView } from "./OrganizationSettingsPageView"; -import { OrganizationSummaryPageView } from "./OrganizationSummaryPageView"; const OrganizationSettingsPage: FC = () => { - const { organization: organizationName } = useParams() as { - organization?: string; - }; - const { organizations } = useOrganizationSettings(); - const feats = useFeatureVisibility(); - const navigate = useNavigate(); const queryClient = useQueryClient(); + const { organization, organizationPermissions } = useOrganizationSettings(); + const updateOrganizationMutation = useMutation( updateOrganization(queryClient), ); @@ -32,50 +22,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} />; - } - - // 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 <Navigate to={`/organizations/${editableOrg.name}`} replace />; - } - return <EmptyState message="No organizations found" />; - } - - 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 || - !feats.multiple_organizations - ) { - return <OrganizationSummaryPageView organization={organization} />; - } - const error = updateOrganizationMutation.error ?? deleteOrganizationMutation.error; diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationSettingsPageView.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationSettingsPageView.tsx index 16738ca7dd52d..08199c0d65f4f 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/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<typeof OrganizationSummaryPageView> = { - title: "pages/OrganizationSummaryPageView", - component: OrganizationSummaryPageView, - args: { - organization: MockOrganization, - }, -}; - -export default meta; -type Story = StoryObj<typeof OrganizationSummaryPageView>; - -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 ( - <div> - <PageHeader - css={{ - // The deployment settings layout already has padding. - paddingTop: 0, - }} - > - <Stack direction="row"> - <Avatar - size="lg" - variant="icon" - src={organization.icon} - fallback={organization.display_name || organization.name} - /> - - <div> - <PageHeaderTitle> - {organization.display_name || organization.name} - </PageHeaderTitle> - {organization.description && ( - <PageHeaderSubtitle> - {organization.description} - </PageHeaderSubtitle> - )} - </div> - </Stack> - </PageHeader> - You are a member of this organization. - </div> - ); -}; 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/pages/TerminalPage/TerminalPage.stories.tsx b/site/src/pages/TerminalPage/TerminalPage.stories.tsx index 4fae86ff8b8ca..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"; @@ -76,6 +77,7 @@ const meta = { key: getAuthorizationKey({ checks: permissionsToCheck }), data: { editWorkspaceProxies: true }, }, + { key: anyOrganizationPermissionsKey, data: {} }, ], chromatic: { delay: 300 }, }, 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/router.tsx b/site/src/router.tsx index 7e7776eeecf18..85133f7e6e6c9 100644 --- a/site/src/router.tsx +++ b/site/src/router.tsx @@ -228,6 +228,10 @@ const AddNewLicensePage = lazy( "./pages/DeploymentSettingsPage/LicensesSettingsPage/AddNewLicensePage" ), ); +const OrganizationRedirect = lazy( + () => import("./pages/OrganizationSettingsPage/OrganizationRedirect"), +); + const CreateOrganizationPage = lazy( () => import("./pages/OrganizationSettingsPage/CreateOrganizationPage"), ); @@ -415,7 +419,7 @@ export const router = createBrowserRouter( <Route path="new" element={<CreateOrganizationPage />} /> {/* General settings for the default org can omit the organization name */} - <Route index element={<OrganizationSettingsPage />} /> + <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 c866c64f15b4e..74d4de9121e2e 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"; @@ -2836,7 +2837,6 @@ export const MockPermissions: Permissions = { readWorkspaceProxies: true, editWorkspaceProxies: true, createOrganization: true, - editAnyOrganization: true, viewAnyGroup: true, createGroup: true, viewAllLicenses: true, @@ -2844,6 +2844,38 @@ export const MockPermissions: Permissions = { viewOrganizationIDPSyncSettings: true, }; +export const MockOrganizationPermissions: OrganizationPermissions = { + viewMembers: true, + editMembers: true, + createGroup: true, + viewGroups: true, + editGroups: true, + editSettings: true, + viewOrgRoles: true, + createOrgRoles: true, + assignOrgRoles: true, + viewProvisioners: true, + viewProvisionerJobs: true, + viewIdpSyncSettings: true, + editIdpSyncSettings: true, +}; + +export const MockNoOrganizationPermissions: OrganizationPermissions = { + viewMembers: false, + editMembers: false, + createGroup: false, + viewGroups: false, + editGroups: false, + editSettings: false, + viewOrgRoles: false, + createOrgRoles: false, + assignOrgRoles: false, + viewProvisioners: false, + viewProvisionerJobs: false, + viewIdpSyncSettings: false, + editIdpSyncSettings: false, +}; + export const MockNoPermissions: Permissions = { createTemplates: false, createUser: false, @@ -2860,7 +2892,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 f1bdc8fadd0f0..2b81bf16cd40f 100644 --- a/site/src/testHelpers/storybook.tsx +++ b/site/src/testHelpers/storybook.tsx @@ -17,6 +17,7 @@ import { MockDefaultOrganization, MockDeploymentConfig, MockEntitlements, + MockOrganizationPermissions, } from "./entities"; export const withDashboardProvider = ( @@ -28,6 +29,7 @@ export const withDashboardProvider = ( experiments = [], showOrganizations = false, organizations = [MockDefaultOrganization], + canViewOrganizationSettings = false, } = parameters; const entitlements: Entitlements = { @@ -48,9 +50,10 @@ export const withDashboardProvider = ( value={{ entitlements, experiments, + appearance: MockAppearanceConfig, organizations, showOrganizations, - appearance: MockAppearanceConfig, + canViewOrganizationSettings, }} > <Story /> @@ -153,12 +156,16 @@ export const withGlobalSnackbar = (Story: FC) => ( </> ); -export const withManagementSettingsProvider = (Story: FC) => { +export const withOrganizationSettingsProvider = (Story: FC) => { return ( <OrganizationSettingsContext.Provider value={{ organizations: [MockDefaultOrganization], + organizationPermissionsByOrganizationId: { + [MockDefaultOrganization.id]: MockOrganizationPermissions, + }, organization: MockDefaultOrganization, + organizationPermissions: MockOrganizationPermissions, }} > <DeploymentSettingsContext.Provider