mirror of
https://github.com/coder/coder.git
synced 2026-09-21 20:51:01 +08:00
fix: fix loading states and permissions checks in organization settings (#16465)
This commit is contained in:
+15
-16
@@ -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},
|
||||
}),
|
||||
|
||||
@@ -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},
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
|
||||
+10
-23
@@ -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>
|
||||
|
||||
+36
-22
@@ -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
@@ -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 />} />
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user