feat(site): add Default Roles section to organization roles page (#26028)

Adds a `Default Roles` section to the org Roles page that edits an org's `default_org_member_roles` via the existing PATCH endpoint.
This commit is contained in:
Steven Masley
2026-06-05 17:47:56 -05:00
committed by GitHub
parent 9d85eb2fa0
commit 4124d5be1e
4 changed files with 389 additions and 2 deletions
@@ -3,6 +3,7 @@ import { useMutation, useQuery, useQueryClient } from "react-query";
import { useParams } from "react-router";
import { toast } from "sonner";
import { getErrorDetail, getErrorMessage } from "#/api/errors";
import { updateOrganization } from "#/api/queries/organizations";
import { deleteOrganizationRole, organizationRoles } from "#/api/queries/roles";
import type { Role } from "#/api/typesGenerated";
import { DeleteDialog } from "#/components/Dialogs/DeleteDialog/DeleteDialog";
@@ -12,6 +13,7 @@ import {
SettingsHeaderDescription,
SettingsHeaderTitle,
} from "#/components/SettingsHeader/SettingsHeader";
import { useDashboard } from "#/modules/dashboard/useDashboard";
import { useFeatureVisibility } from "#/modules/dashboard/useFeatureVisibility";
import { useOrganizationSettings } from "#/modules/management/OrganizationSettingsLayout";
import { RequirePermission } from "#/modules/permissions/RequirePermission";
@@ -25,6 +27,10 @@ const CustomRolesPage: FC = () => {
organization: string;
};
const { organization, organizationPermissions } = useOrganizationSettings();
const { experiments, entitlements } = useDashboard();
const defaultRolesEnabled = experiments.includes("minimum-implicit-member");
const defaultRolesEntitled =
entitlements.features.multiple_organizations.enabled;
const [roleToDelete, setRoleToDelete] = useState<Role>();
@@ -39,6 +45,9 @@ const CustomRolesPage: FC = () => {
const deleteRoleMutation = useMutation(
deleteOrganizationRole(queryClient, organizationName),
);
const updateOrganizationMutation = useMutation(
updateOrganization(queryClient),
);
useEffect(() => {
if (organizationRolesQuery.error) {
@@ -80,13 +89,33 @@ const CustomRolesPage: FC = () => {
</div>
<CustomRolesPageView
organization={organization}
builtInRoles={builtInRoles}
customRoles={customRoles}
onDeleteRole={setRoleToDelete}
canCreateOrgRole={organizationPermissions?.createOrgRoles ?? false}
canUpdateOrgRole={organizationPermissions?.updateOrgRoles ?? false}
canDeleteOrgRole={organizationPermissions?.deleteOrgRoles ?? false}
canEditDefaultRoles={organizationPermissions?.editSettings ?? false}
isCustomRolesEnabled={isCustomRolesEnabled}
defaultRolesEnabled={defaultRolesEnabled}
defaultRolesEntitled={defaultRolesEntitled}
availableOrgRoles={organizationRolesQuery.data}
isUpdatingDefaultRoles={updateOrganizationMutation.isPending}
onUpdateDefaultRoles={async (roles) => {
try {
await updateOrganizationMutation.mutateAsync({
organizationId: organization.id,
req: { default_org_member_roles: roles },
});
toast.success("Default roles updated.");
} catch (error) {
toast.error(
getErrorMessage(error, "Failed to update default roles."),
{ description: getErrorDetail(error) },
);
}
}}
/>
<DeleteDialog
@@ -1,17 +1,59 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { action } from "storybook/actions";
import { expect, userEvent, within } from "storybook/test";
import type { AssignableRoles } from "#/api/typesGenerated";
import {
MockOrganization,
MockOrganizationAuditorRole,
MockRoleWithOrgPermissions,
} from "#/testHelpers/entities";
import { CustomRolesPageView } from "./CustomRolesPageView";
const mockOrgRoles: AssignableRoles[] = [
{
name: "organization-workspace-access",
display_name: "Organization Workspace Access",
organization_id: MockOrganization.id,
site_permissions: [],
organization_permissions: [],
organization_member_permissions: [],
user_permissions: [],
assignable: true,
built_in: true,
},
{
name: "organization-admin",
display_name: "Organization Admin",
organization_id: MockOrganization.id,
site_permissions: [],
organization_permissions: [],
organization_member_permissions: [],
user_permissions: [],
assignable: true,
built_in: true,
},
{
name: "agents-access",
display_name: "Agents Access",
organization_id: MockOrganization.id,
site_permissions: [],
organization_permissions: [],
organization_member_permissions: [],
user_permissions: [],
assignable: true,
built_in: true,
},
];
const meta: Meta<typeof CustomRolesPageView> = {
title: "pages/OrganizationCustomRolesPage",
component: CustomRolesPageView,
args: {
organization: MockOrganization,
builtInRoles: [MockRoleWithOrgPermissions],
customRoles: [MockRoleWithOrgPermissions],
canCreateOrgRole: true,
canEditDefaultRoles: true,
isCustomRolesEnabled: true,
},
};
@@ -66,3 +108,98 @@ export const EmptyTableUserWithPermission: Story = {
customRoles: [],
},
};
export const DefaultRolesHidden: Story = {
args: {
defaultRolesEnabled: false,
availableOrgRoles: mockOrgRoles,
onUpdateDefaultRoles: async () => {
action("onUpdateDefaultRoles")();
},
},
play: async ({ canvasElement }) => {
const body = within(canvasElement.ownerDocument.body);
expect(body.queryByText("Default Roles")).toBeNull();
},
};
export const DefaultRolesEnabled: Story = {
args: {
defaultRolesEnabled: true,
defaultRolesEntitled: true,
availableOrgRoles: mockOrgRoles,
onUpdateDefaultRoles: async () => {
action("onUpdateDefaultRoles")();
},
},
};
export const DefaultRolesNotEntitled: Story = {
args: {
defaultRolesEnabled: true,
defaultRolesEntitled: false,
availableOrgRoles: mockOrgRoles,
onUpdateDefaultRoles: async () => {
action("onUpdateDefaultRoles")();
},
},
play: async ({ canvasElement }) => {
const body = within(canvasElement.ownerDocument.body);
const editButton = await body.findByRole("button", {
name: /edit default roles/i,
});
expect(editButton).toBeDisabled();
await body.findByText(/requires a Premium license/i);
},
};
export const DefaultRolesEmpty: Story = {
args: {
organization: {
...MockOrganization,
default_org_member_roles: [],
},
defaultRolesEnabled: true,
defaultRolesEntitled: true,
availableOrgRoles: mockOrgRoles,
onUpdateDefaultRoles: async () => {
action("onUpdateDefaultRoles")();
},
},
};
export const DefaultRolesHiddenWithoutEditPermission: Story = {
args: {
defaultRolesEnabled: true,
defaultRolesEntitled: true,
canEditDefaultRoles: false,
availableOrgRoles: mockOrgRoles,
onUpdateDefaultRoles: async () => {
action("onUpdateDefaultRoles")();
},
},
play: async ({ canvasElement }) => {
const body = within(canvasElement.ownerDocument.body);
expect(body.queryByText("Default Roles")).toBeNull();
},
};
export const DefaultRolesEditDialog: Story = {
args: {
defaultRolesEnabled: true,
defaultRolesEntitled: true,
availableOrgRoles: mockOrgRoles,
onUpdateDefaultRoles: async () => {
action("onUpdateDefaultRoles")();
},
},
play: async ({ canvasElement }) => {
const user = userEvent.setup();
const body = within(canvasElement.ownerDocument.body);
const editButton = await body.findByRole("button", {
name: /edit default roles/i,
});
await user.click(editButton);
await body.findByRole("heading", { name: /edit default roles/i });
},
};
@@ -1,7 +1,8 @@
import { EllipsisVerticalIcon, PlusIcon } from "lucide-react";
import type { FC } from "react";
import { type FC, useState } from "react";
import { Link as RouterLink, useNavigate } from "react-router";
import type { AssignableRoles, Role } from "#/api/typesGenerated";
import type { AssignableRoles, Organization, Role } from "#/api/typesGenerated";
import { PremiumBadge } from "#/components/Badges/Badges";
import { Button, Button as ShadcnButton } from "#/components/Button/Button";
import {
DropdownMenu,
@@ -25,27 +26,45 @@ import {
TableRowSkeleton,
} from "#/components/TableLoader/TableLoader";
import { docs } from "#/utils/docs";
import { DefaultRolesDialog } from "./DefaultRolesDialog";
import { PermissionPillsList } from "./PermissionPillsList";
interface CustomRolesPageViewProps {
organization: Organization;
builtInRoles: AssignableRoles[] | undefined;
customRoles: AssignableRoles[] | undefined;
onDeleteRole: (role: Role) => void;
canCreateOrgRole: boolean;
canUpdateOrgRole: boolean;
canDeleteOrgRole: boolean;
canEditDefaultRoles: boolean;
isCustomRolesEnabled: boolean;
defaultRolesEnabled?: boolean;
defaultRolesEntitled?: boolean;
availableOrgRoles?: AssignableRoles[];
onUpdateDefaultRoles?: (roles: string[]) => Promise<void>;
isUpdatingDefaultRoles?: boolean;
}
export const CustomRolesPageView: FC<CustomRolesPageViewProps> = ({
organization,
builtInRoles,
customRoles,
onDeleteRole,
canCreateOrgRole,
canUpdateOrgRole,
canDeleteOrgRole,
canEditDefaultRoles,
isCustomRolesEnabled,
defaultRolesEnabled,
defaultRolesEntitled,
availableOrgRoles,
onUpdateDefaultRoles,
isUpdatingDefaultRoles,
}) => {
const showDefaultRoles =
defaultRolesEnabled && canEditDefaultRoles && Boolean(onUpdateDefaultRoles);
return (
<div className="flex flex-col gap-8">
{!isCustomRolesEnabled && (
@@ -55,6 +74,15 @@ export const CustomRolesPageView: FC<CustomRolesPageViewProps> = ({
documentationLink={docs("/admin/users/groups-roles")}
/>
)}
{showDefaultRoles && onUpdateDefaultRoles && (
<DefaultRolesSection
organization={organization}
availableOrgRoles={availableOrgRoles}
defaultRolesEntitled={Boolean(defaultRolesEntitled)}
isUpdatingDefaultRoles={Boolean(isUpdatingDefaultRoles)}
onUpdateDefaultRoles={onUpdateDefaultRoles}
/>
)}
<div className="flex flex-row gap-4 items-baseline justify-between">
<span>
<h2 className="mb-0 text-lg">Custom Roles</h2>
@@ -99,6 +127,100 @@ export const CustomRolesPageView: FC<CustomRolesPageViewProps> = ({
);
};
interface DefaultRolesSectionProps {
organization: Organization;
availableOrgRoles?: AssignableRoles[];
defaultRolesEntitled: boolean;
isUpdatingDefaultRoles: boolean;
onUpdateDefaultRoles: (roles: string[]) => Promise<void>;
}
const DefaultRolesSection: FC<DefaultRolesSectionProps> = ({
organization,
availableOrgRoles,
defaultRolesEntitled,
isUpdatingDefaultRoles,
onUpdateDefaultRoles,
}) => {
const [isEditing, setIsEditing] = useState(false);
return (
<div className="flex flex-col gap-3">
<div className="flex flex-row gap-4 items-baseline justify-between">
<span>
<h2 className="mb-0 text-lg flex items-center gap-2">
Default Roles
{!defaultRolesEntitled && <PremiumBadge />}
</h2>
<span className="text-sm text-content-secondary leading-relaxed">
Roles attached to every member of this organization. An empty
selection limits new members to the floor permissions only.
</span>
</span>
<Button
type="button"
variant="outline"
onClick={() => setIsEditing(true)}
disabled={isUpdatingDefaultRoles || !defaultRolesEntitled}
>
Edit default roles
</Button>
</div>
<div className="text-sm">
{organization.default_org_member_roles.length === 0 ? (
<span className="text-content-secondary">
No default roles. New members receive only the floor.
</span>
) : (
<DefaultRolesSummary
roleNames={organization.default_org_member_roles}
availableRoles={availableOrgRoles}
/>
)}
</div>
{!defaultRolesEntitled && (
<p className="text-xs text-content-secondary mt-0 mb-0">
Editing organization settings requires a Premium license.
</p>
)}
<DefaultRolesDialog
open={isEditing}
currentRoles={organization.default_org_member_roles}
availableRoles={availableOrgRoles}
onCancel={() => setIsEditing(false)}
onConfirm={async (roles) => {
await onUpdateDefaultRoles(roles);
setIsEditing(false);
}}
isUpdating={isUpdatingDefaultRoles}
/>
</div>
);
};
interface DefaultRolesSummaryProps {
roleNames: readonly string[];
availableRoles?: AssignableRoles[];
}
const DefaultRolesSummary: FC<DefaultRolesSummaryProps> = ({
roleNames,
availableRoles,
}) => {
const displayNameFor = (name: string): string => {
const role = availableRoles?.find((r) => r.name === name);
return role?.display_name || role?.name || name;
};
return (
<ul className="list-disc pl-5 m-0 flex flex-col gap-1">
{roleNames.map((name) => (
<li key={name}>{displayNameFor(name)}</li>
))}
</ul>
);
};
interface RoleTableProps {
roles: AssignableRoles[] | undefined;
isCustomRolesEnabled: boolean;
@@ -0,0 +1,99 @@
import type { FC } from "react";
import { useState } from "react";
import type { AssignableRoles } from "#/api/typesGenerated";
import {
Dialog,
DialogActions,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "#/components/Dialog/Dialog";
import { RoleSelector } from "#/modules/roles/RoleSelector";
interface DefaultRolesDialogProps {
open: boolean;
currentRoles: readonly string[];
availableRoles?: AssignableRoles[];
onCancel: () => void;
onConfirm: (roles: string[]) => Promise<void>;
isUpdating: boolean;
}
export const DefaultRolesDialog: FC<DefaultRolesDialogProps> = ({
open,
currentRoles,
availableRoles,
onCancel,
onConfirm,
isUpdating,
}) => {
if (!open) {
return null;
}
return (
<ActiveDefaultRolesDialog
currentRoles={currentRoles}
availableRoles={availableRoles ?? []}
onCancel={onCancel}
onConfirm={onConfirm}
isUpdating={isUpdating}
/>
);
};
interface ActiveProps {
currentRoles: readonly string[];
availableRoles: AssignableRoles[];
onCancel: () => void;
onConfirm: (roles: string[]) => Promise<void>;
isUpdating: boolean;
}
const ActiveDefaultRolesDialog: FC<ActiveProps> = ({
currentRoles,
availableRoles,
onCancel,
onConfirm,
isUpdating,
}) => {
const [selected, setSelected] = useState<Set<string>>(
() => new Set(currentRoles),
);
return (
<Dialog
open
onOpenChange={(isOpen) => {
if (!isOpen) {
onCancel();
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit default roles</DialogTitle>
<DialogDescription>
These roles are attached to every member of this organization. Use
an empty selection to grant new members only the floor.
</DialogDescription>
</DialogHeader>
<RoleSelector
hideLabel
availableRoles={availableRoles}
selectedRoles={selected}
onChange={setSelected}
/>
<DialogFooter>
<DialogActions
onCancel={onCancel}
onConfirm={() => onConfirm([...selected])}
confirmLoading={isUpdating}
/>
</DialogFooter>
</DialogContent>
</Dialog>
);
};