diff --git a/site/src/pages/OrganizationSettingsPage/CustomRolesPage/CustomRolesPage.tsx b/site/src/pages/OrganizationSettingsPage/CustomRolesPage/CustomRolesPage.tsx index 546408bb87d8f..03174f8bb79dc 100644 --- a/site/src/pages/OrganizationSettingsPage/CustomRolesPage/CustomRolesPage.tsx +++ b/site/src/pages/OrganizationSettingsPage/CustomRolesPage/CustomRolesPage.tsx @@ -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(); @@ -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 = () => { { + 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) }, + ); + } + }} /> = { 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 }); + }, +}; diff --git a/site/src/pages/OrganizationSettingsPage/CustomRolesPage/CustomRolesPageView.tsx b/site/src/pages/OrganizationSettingsPage/CustomRolesPage/CustomRolesPageView.tsx index db6556027eb6a..2ebe55b3aa1ba 100644 --- a/site/src/pages/OrganizationSettingsPage/CustomRolesPage/CustomRolesPageView.tsx +++ b/site/src/pages/OrganizationSettingsPage/CustomRolesPage/CustomRolesPageView.tsx @@ -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; + isUpdatingDefaultRoles?: boolean; } export const CustomRolesPageView: FC = ({ + organization, builtInRoles, customRoles, onDeleteRole, canCreateOrgRole, canUpdateOrgRole, canDeleteOrgRole, + canEditDefaultRoles, isCustomRolesEnabled, + defaultRolesEnabled, + defaultRolesEntitled, + availableOrgRoles, + onUpdateDefaultRoles, + isUpdatingDefaultRoles, }) => { + const showDefaultRoles = + defaultRolesEnabled && canEditDefaultRoles && Boolean(onUpdateDefaultRoles); + return (
{!isCustomRolesEnabled && ( @@ -55,6 +74,15 @@ export const CustomRolesPageView: FC = ({ documentationLink={docs("/admin/users/groups-roles")} /> )} + {showDefaultRoles && onUpdateDefaultRoles && ( + + )}

Custom Roles

@@ -99,6 +127,100 @@ export const CustomRolesPageView: FC = ({ ); }; +interface DefaultRolesSectionProps { + organization: Organization; + availableOrgRoles?: AssignableRoles[]; + defaultRolesEntitled: boolean; + isUpdatingDefaultRoles: boolean; + onUpdateDefaultRoles: (roles: string[]) => Promise; +} + +const DefaultRolesSection: FC = ({ + organization, + availableOrgRoles, + defaultRolesEntitled, + isUpdatingDefaultRoles, + onUpdateDefaultRoles, +}) => { + const [isEditing, setIsEditing] = useState(false); + + return ( +
+
+ +

+ Default Roles + {!defaultRolesEntitled && } +

+ + Roles attached to every member of this organization. An empty + selection limits new members to the floor permissions only. + +
+ +
+
+ {organization.default_org_member_roles.length === 0 ? ( + + No default roles. New members receive only the floor. + + ) : ( + + )} +
+ {!defaultRolesEntitled && ( +

+ Editing organization settings requires a Premium license. +

+ )} + setIsEditing(false)} + onConfirm={async (roles) => { + await onUpdateDefaultRoles(roles); + setIsEditing(false); + }} + isUpdating={isUpdatingDefaultRoles} + /> +
+ ); +}; + +interface DefaultRolesSummaryProps { + roleNames: readonly string[]; + availableRoles?: AssignableRoles[]; +} + +const DefaultRolesSummary: FC = ({ + roleNames, + availableRoles, +}) => { + const displayNameFor = (name: string): string => { + const role = availableRoles?.find((r) => r.name === name); + return role?.display_name || role?.name || name; + }; + + return ( +
    + {roleNames.map((name) => ( +
  • {displayNameFor(name)}
  • + ))} +
+ ); +}; + interface RoleTableProps { roles: AssignableRoles[] | undefined; isCustomRolesEnabled: boolean; diff --git a/site/src/pages/OrganizationSettingsPage/CustomRolesPage/DefaultRolesDialog.tsx b/site/src/pages/OrganizationSettingsPage/CustomRolesPage/DefaultRolesDialog.tsx new file mode 100644 index 0000000000000..86f89ba295596 --- /dev/null +++ b/site/src/pages/OrganizationSettingsPage/CustomRolesPage/DefaultRolesDialog.tsx @@ -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; + isUpdating: boolean; +} + +export const DefaultRolesDialog: FC = ({ + open, + currentRoles, + availableRoles, + onCancel, + onConfirm, + isUpdating, +}) => { + if (!open) { + return null; + } + + return ( + + ); +}; + +interface ActiveProps { + currentRoles: readonly string[]; + availableRoles: AssignableRoles[]; + onCancel: () => void; + onConfirm: (roles: string[]) => Promise; + isUpdating: boolean; +} + +const ActiveDefaultRolesDialog: FC = ({ + currentRoles, + availableRoles, + onCancel, + onConfirm, + isUpdating, +}) => { + const [selected, setSelected] = useState>( + () => new Set(currentRoles), + ); + + return ( + { + if (!isOpen) { + onCancel(); + } + }} + > + + + Edit default roles + + These roles are attached to every member of this organization. Use + an empty selection to grant new members only the floor. + + + + + onConfirm([...selected])} + confirmLoading={isUpdating} + /> + + + + ); +};