mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
@@ -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
|
||||
|
||||
+137
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user