fix: fix loading states and permissions checks in organization settings (#16465)

This commit is contained in:
ケイラ
2025-02-19 11:48:13 -07:00
committed by GitHub
parent e59c54a539
commit deadac0b91
35 changed files with 710 additions and 865 deletions
+15 -16
View File
@@ -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},
}),
+3 -2
View File
@@ -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},
},
},
{
+28 -99
View File
@@ -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<string, AuthorizationResponse>,
);
{} as Record<string, Partial<OrganizationPermissions>>,
) as Record<string, OrganizationPermissions>;
},
};
};
export const anyOrganizationPermissionsKey = [
"authorization",
"anyOrganization",
];
export const anyOrganizationPermissions = () => {
return {
queryKey: anyOrganizationPermissionsKey,
queryFn: () =>
API.checkAuthorization({
checks: anyOrganizationPermissionChecks,
}) as Promise<AnyOrganizationPermissions>,
};
};
export const getOrganizationIdpSyncClaimFieldValuesKey = (
organization: string,
field: string,
-8
View File
@@ -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",
@@ -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<DashboardValue | undefined>(
@@ -33,12 +38,16 @@ export const DashboardProvider: FC<PropsWithChildren> = ({ 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 <ErrorAlert error={error} />;
@@ -48,7 +57,8 @@ export const DashboardProvider: FC<PropsWithChildren> = ({ children }) => {
!entitlementsQuery.data ||
!appearanceQuery.data ||
!experimentsQuery.data ||
!organizationsQuery.data;
!organizationsQuery.data ||
!anyOrganizationPermissionsQuery.data;
if (isLoading) {
return <Loader fullscreen />;
@@ -58,6 +68,7 @@ export const DashboardProvider: FC<PropsWithChildren> = ({ children }) => {
const organizationsEnabled = selectFeatureVisibility(
entitlementsQuery.data,
).multiple_organizations;
const showOrganizations = hasMultipleOrganizations || organizationsEnabled;
return (
<DashboardContext.Provider
@@ -66,7 +77,10 @@ export const DashboardProvider: FC<PropsWithChildren> = ({ children }) => {
experiments: experimentsQuery.data,
appearance: appearanceQuery.data,
organizations: organizationsQuery.data,
showOrganizations: hasMultipleOrganizations || organizationsEnabled,
showOrganizations,
canViewOrganizationSettings:
showOrganizations &&
canViewAnyOrganization(anyOrganizationPermissionsQuery.data),
}}
>
{children}
+4 -5
View File
@@ -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;
@@ -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;
@@ -1,20 +1,24 @@
import type { AuthorizationResponse, Organization } from "api/typesGenerated";
import { organizationsPermissions } from "api/queries/organizations";
import type { 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,
canViewOrganization,
} from "./organizationPermissions";
export const OrganizationSettingsContext = createContext<
OrganizationSettingsValue | undefined
@@ -22,7 +26,12 @@ export const OrganizationSettingsContext = createContext<
type OrganizationSettingsValue = Readonly<{
organizations: readonly Organization[];
organizationPermissionsByOrganizationId: Record<
string,
OrganizationPermissions
>;
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 <ErrorAlert error={orgPermissionsQuery.error} />;
}
if (!orgPermissionsQuery.data) {
return <Loader />;
}
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 <NotFoundPage />;
}
return (
<RequirePermission isFeatureVisible={canViewOrganizationSettingsPage}>
<OrganizationSettingsContext.Provider
value={{
organizations,
organization,
}}
>
<div>
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbPage>Admin Settings</BreadcrumbPage>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage className="flex items-center gap-2">
Organizations
</BreadcrumbPage>
</BreadcrumbItem>
{organization && (
<>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage className="text-content-primary">
<Avatar
key={organization.id}
size="sm"
fallback={organization.display_name}
src={organization.icon}
/>
{organization?.name}
</BreadcrumbPage>
</BreadcrumbItem>
</>
)}
</BreadcrumbList>
</Breadcrumb>
<hr className="h-px border-none bg-border" />
<div className="px-10 max-w-screen-2xl">
<Suspense fallback={<Loader />}>
<Outlet />
</Suspense>
</div>
<OrganizationSettingsContext.Provider
value={{
organizations: viewableOrganizations,
organizationPermissionsByOrganizationId: orgPermissionsQuery.data,
organization,
organizationPermissions,
}}
>
<div>
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbPage>Admin Settings</BreadcrumbPage>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage className="flex items-center gap-2">
Organizations
</BreadcrumbPage>
</BreadcrumbItem>
{organization && (
<>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage className="text-content-primary">
<Avatar
key={organization.id}
size="sm"
fallback={organization.display_name}
src={organization.icon}
/>
{organization.display_name}
</BreadcrumbPage>
</BreadcrumbItem>
</>
)}
</BreadcrumbList>
</Breadcrumb>
<hr className="h-px border-none bg-border" />
<div className="px-10 max-w-screen-2xl">
<Suspense fallback={<Loader />}>
<Outlet />
</Suspense>
</div>
</OrganizationSettingsContext.Provider>
</RequirePermission>
</div>
</OrganizationSettingsContext.Provider>
);
};
@@ -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 (
<OrganizationSidebarView
activeOrganization={organization}
organizations={editableOrgs}
permissions={permissions}
/>
<BaseSidebar>
<OrganizationSidebarView
activeOrganization={organization}
orgPermissions={organizationPermissions}
organizations={organizations}
permissions={permissions}
/>
</BaseSidebar>
);
};
@@ -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<typeof OrganizationSidebarView> = {
title: "modules/management/OrganizationSidebarView",
@@ -20,26 +19,7 @@ const meta: Meta<typeof OrganizationSidebarView> = {
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<typeof OrganizationSidebarView> = {
export default meta;
type Story = StoryObj<typeof OrganizationSidebarView>;
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");
@@ -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<SidebarProps> = ({
activeOrganization,
organizations,
permissions,
}) => {
const { showOrganizations } = useDashboard();
return (
<BaseSidebar>
{showOrganizations && (
<OrganizationsSettingsNavigation
activeOrganization={activeOrganization}
organizations={organizations}
permissions={permissions}
/>
)}
</BaseSidebar>
);
};
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 <Loader />;
}
> = ({ 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"
>
<div className="flex flex-row gap-2 items-center p-2 truncate">
{activeOrganization && (
<Avatar
size="sm"
src={activeOrganization.icon}
fallback={activeOrganization.display_name}
/>
{activeOrganization ? (
<>
<Avatar
size="sm"
src={activeOrganization.icon}
fallback={activeOrganization.display_name}
/>
<span className="truncate">
{activeOrganization.display_name || activeOrganization.name}
</span>
</>
) : (
<span className="truncate">No organization selected</span>
)}
<span className="truncate">
{activeOrganization?.display_name || activeOrganization?.name}
</span>
</div>
<ChevronDown />
</Button>
@@ -134,39 +89,33 @@ const OrganizationsSettingsNavigation: FC<
<CommandList>
<CommandEmpty>No organization found.</CommandEmpty>
<CommandGroup className="pb-2">
{sortedOrganizations.length > 1 && (
<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>
)}
<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 && (
<>
@@ -190,58 +139,69 @@ const OrganizationsSettingsNavigation: FC<
</Command>
</PopoverContent>
</Popover>
<OrganizationSettingsNavigation
key={activeOrganization.id}
organization={activeOrganization}
/>
{activeOrganization && orgPermissions && (
<OrganizationSettingsNavigation
key={activeOrganization.id}
organization={activeOrganization}
orgPermissions={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 (
<>
<div className="flex flex-col gap-1 my-2">
{organization.permissions.editMembers && (
{orgPermissions.viewMembers && (
<SettingsSidebarNavItem end href={urlForSubpage(organization.name)}>
Members
</SettingsSidebarNavItem>
)}
{organization.permissions.editGroups && (
{orgPermissions.viewGroups && (
<SettingsSidebarNavItem
href={urlForSubpage(organization.name, "groups")}
>
Groups
</SettingsSidebarNavItem>
)}
{organization.permissions.assignOrgRole && (
{orgPermissions.viewOrgRoles && (
<SettingsSidebarNavItem
href={urlForSubpage(organization.name, "roles")}
>
Roles
</SettingsSidebarNavItem>
)}
{organization.permissions.viewProvisioners && (
<SettingsSidebarNavItem
href={urlForSubpage(organization.name, "provisioners")}
>
Provisioners
</SettingsSidebarNavItem>
)}
{organization.permissions.viewIdpSyncSettings && (
{orgPermissions.viewProvisioners &&
orgPermissions.viewProvisionerJobs && (
<SettingsSidebarNavItem
href={urlForSubpage(organization.name, "provisioners")}
>
Provisioners
</SettingsSidebarNavItem>
)}
{orgPermissions.viewIdpSyncSettings && (
<SettingsSidebarNavItem
href={urlForSubpage(organization.name, "idp-sync")}
>
IdP Sync
</SettingsSidebarNavItem>
)}
{organization.permissions.editOrganization && (
{orgPermissions.editSettings && (
<SettingsSidebarNavItem
href={urlForSubpage(organization.name, "settings")}
>
@@ -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<string, AuthorizationCheck>;
/**
* 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<string, AuthorizationCheck>;
/**
* 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)
);
};
@@ -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<typeof NotificationsPage>;
+13 -4
View File
@@ -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 <EmptyState message="Organization not found" />;
}
const permissions = permissionsQuery.data;
if (!permissions) {
if (permissionsQuery.isLoading) {
return <Loader />;
}
const permissions = permissionsQuery.data?.[organization.id];
if (!permissions) {
return <ErrorAlert error={permissionsQuery.error} />;
}
return (
<>
<Helmet>
@@ -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<
@@ -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 <Loader />;
}
if (!organizationPermissions) {
return <ErrorAlert error="Failed to load organization permissions" />;
}
return (
<>
<Helmet>
@@ -80,7 +81,7 @@ export const CreateEditRolePage: FC = () => {
: createOrganizationRoleMutation.isLoading
}
organizationName={organizationName}
canAssignOrgRole={permissions.assignOrgRole}
canAssignOrgRole={organizationPermissions.assignOrgRoles}
/>
</>
);
@@ -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<Role>();
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 <Loader />;
}
@@ -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}
/>
@@ -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,
},
};
@@ -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>
@@ -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,
]),
),
);
}),
);
});
@@ -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,17 +24,17 @@ 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 groupsByUserIdQuery = useQuery(
groupsByUserIdInOrganization(organizationName),
);
const { organization, organizationPermissions } = useOrganizationSettings();
const membersQuery = useQuery(organizationMembers(organizationName));
const organizationRolesQuery = useQuery(organizationRoles(organizationName));
const groupsByUserIdQuery = useQuery(
groupsByUserIdInOrganization(organizationName),
);
const members = membersQuery.data?.map((member) => {
const groups = groupsByUserIdQuery.data?.get(member.user_id) ?? [];
@@ -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<OrganizationMemberWithUserData>();
const permissions = permissionsQuery.data;
if (!permissions) {
return <Loader />;
if (!organization || !organizationPermissions) {
return <EmptyState message="Organization not found" />;
}
const helmet = organization && (
const helmet = (
<Helmet>
<title>
{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
@@ -79,6 +79,7 @@ export const OrganizationMembersPageView: FC<
onSubmit={addMember}
/>
)}
<Table>
<TableHeader>
<TableRow>
@@ -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}`,
);
});
});
@@ -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;
@@ -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,
},
},
},
],
},
};
@@ -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;
@@ -75,7 +75,6 @@ export const OrganizationSettingsPageView: FC<
)}
<HorizontalForm
data-testid="org-settings-form"
onSubmit={form.handleSubmit}
aria-label="Organization settings form"
>
@@ -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,
},
};
@@ -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>
);
};
@@ -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>
@@ -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 },
},
@@ -565,6 +565,7 @@ describe("WorkspacePage", () => {
experiments: [],
organizations: [MockOrganization],
showOrganizations: true,
canViewOrganizationSettings: true,
}}
>
{children}
+5 -1
View File
@@ -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 />} />
+33 -2
View File
@@ -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,
+9 -2
View File
@@ -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