From 4721fd9923aab187376b81738069dd4c9b15c6d9 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Wed, 3 Jun 2026 19:17:15 +0000 Subject: [PATCH 1/3] feat(site): add Default Roles section to organization settings Adds a new Default Roles section to the org settings page (gated on the minimum-implicit-member experiment) that edits an org's default_org_member_roles via the existing PATCH endpoint. Uses the existing RoleSelector primitive via a new DefaultRolesDialog sibling. RoleSelectorDialog itself is too user-centric (avatar header, username/email props) to reuse here. Refs #25936. --- .../DefaultRolesDialog.tsx | 99 +++++++++++++++ .../OrganizationSettingsPage.tsx | 29 +++++ .../OrganizationSettingsPageView.stories.tsx | 118 +++++++++++++++++- .../OrganizationSettingsPageView.tsx | 105 ++++++++++++++++ 4 files changed, 350 insertions(+), 1 deletion(-) create mode 100644 site/src/pages/OrganizationSettingsPage/DefaultRolesDialog.tsx diff --git a/site/src/pages/OrganizationSettingsPage/DefaultRolesDialog.tsx b/site/src/pages/OrganizationSettingsPage/DefaultRolesDialog.tsx new file mode 100644 index 00000000000..86f89ba2955 --- /dev/null +++ b/site/src/pages/OrganizationSettingsPage/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} + /> + + + + ); +}; diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationSettingsPage.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationSettingsPage.tsx index 3663d7e9268..335b9593447 100644 --- a/site/src/pages/OrganizationSettingsPage/OrganizationSettingsPage.tsx +++ b/site/src/pages/OrganizationSettingsPage/OrganizationSettingsPage.tsx @@ -9,8 +9,10 @@ import { updateOrganization, workspaceSharingSettings, } from "#/api/queries/organizations"; +import { organizationRoles } from "#/api/queries/roles"; import type { ShareableWorkspaceOwners } from "#/api/typesGenerated"; import { EmptyState } from "#/components/EmptyState/EmptyState"; +import { useDashboard } from "#/modules/dashboard/useDashboard"; import { useOrganizationSettings } from "#/modules/management/OrganizationSettingsLayout"; import { RequirePermission } from "#/modules/permissions/RequirePermission"; import { pageTitle } from "#/utils/page"; @@ -26,6 +28,10 @@ const OrganizationSettingsPage: FC = () => { const navigate = useNavigate(); const queryClient = useQueryClient(); const { organization, organizationPermissions } = useOrganizationSettings(); + const { experiments, entitlements } = useDashboard(); + const defaultRolesEnabled = experiments.includes("minimum-implicit-member"); + const defaultRolesEntitled = + entitlements.features.multiple_organizations.enabled; const updateOrganizationMutation = useMutation( updateOrganization(queryClient), @@ -43,6 +49,11 @@ const OrganizationSettingsPage: FC = () => { patchWorkspaceSharingSettings(organization?.id ?? "", queryClient), ); + const orgRolesQuery = useQuery({ + ...organizationRoles(organization?.name ?? ""), + enabled: defaultRolesEnabled && Boolean(organization), + }); + if (!organization) { return ; } @@ -126,6 +137,24 @@ const OrganizationSettingsPage: FC = () => { } onChangeShareableOwners={handleChangeShareableOwners} isTogglingWorkspaceSharing={patchSharingSettingsMutation.isPending} + defaultRolesEnabled={defaultRolesEnabled} + defaultRolesEntitled={defaultRolesEntitled} + availableOrgRoles={orgRolesQuery.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) }, + ); + } + }} /> ); diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationSettingsPageView.stories.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationSettingsPageView.stories.tsx index 828ed18940e..06b0eb59ea9 100644 --- a/site/src/pages/OrganizationSettingsPage/OrganizationSettingsPageView.stories.tsx +++ b/site/src/pages/OrganizationSettingsPage/OrganizationSettingsPageView.stories.tsx @@ -1,6 +1,7 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { action } from "storybook/actions"; -import { userEvent, within } from "storybook/test"; +import { expect, userEvent, within } from "storybook/test"; +import type { AssignableRoles } from "#/api/typesGenerated"; import { chromatic } from "#/testHelpers/chromatic"; import { MockDefaultOrganization, @@ -8,6 +9,42 @@ import { } from "#/testHelpers/entities"; import { OrganizationSettingsPageView } from "./OrganizationSettingsPageView"; +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 = { title: "pages/OrganizationSettingsPageView", component: OrganizationSettingsPageView, @@ -86,3 +123,82 @@ export const RestrictToServiceAccountsDialog: Story = { await user.click(radio); }, }; + +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 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/OrganizationSettingsPageView.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationSettingsPageView.tsx index f9e904581ae..7c52fbe7345 100644 --- a/site/src/pages/OrganizationSettingsPage/OrganizationSettingsPageView.tsx +++ b/site/src/pages/OrganizationSettingsPage/OrganizationSettingsPageView.tsx @@ -4,12 +4,14 @@ import { type FC, useState } from "react"; import * as Yup from "yup"; import { isApiValidationError } from "#/api/errors"; import type { + AssignableRoles, Organization, ShareableWorkspaceOwners, UpdateOrganizationRequest, } from "#/api/typesGenerated"; import { Alert, AlertTitle } from "#/components/Alert/Alert"; import { ErrorAlert } from "#/components/Alert/ErrorAlert"; +import { PremiumBadge } from "#/components/Badges/Badges"; import { Button } from "#/components/Button/Button"; import { Checkbox } from "#/components/Checkbox/Checkbox"; import { DeleteDialog } from "#/components/Dialogs/DeleteDialog/DeleteDialog"; @@ -32,6 +34,7 @@ import { nameValidator, onChangeTrimmed, } from "#/utils/formUtils"; +import { DefaultRolesDialog } from "./DefaultRolesDialog"; import { DisableWorkspaceSharingDialog } from "./DisableWorkspaceSharingDialog"; import { HorizontalContainer, HorizontalSection } from "./Horizontal"; @@ -56,6 +59,21 @@ interface OrganizationSettingsPageViewProps { shareableWorkspaceOwners: ShareableWorkspaceOwners; onChangeShareableOwners: (value: ShareableWorkspaceOwners) => void; isTogglingWorkspaceSharing: boolean; + + /** + * Whether to render the Default Roles section. Gated on the + * minimum-implicit-member experiment at the page level. + */ + defaultRolesEnabled?: boolean; + /** + * Whether the deployment is entitled to multi-org features. The + * underlying PATCH endpoint is multi-org gated, so the edit button + * is disabled when this is false. + */ + defaultRolesEntitled?: boolean; + availableOrgRoles?: AssignableRoles[]; + onUpdateDefaultRoles?: (roles: string[]) => Promise; + isUpdatingDefaultRoles?: boolean; } export const OrganizationSettingsPageView: FC< @@ -69,6 +87,11 @@ export const OrganizationSettingsPageView: FC< shareableWorkspaceOwners, onChangeShareableOwners, isTogglingWorkspaceSharing, + defaultRolesEnabled, + defaultRolesEntitled, + availableOrgRoles, + onUpdateDefaultRoles, + isUpdatingDefaultRoles, }) => { const form = useFormik({ initialValues: { @@ -86,6 +109,7 @@ export const OrganizationSettingsPageView: FC< const [isDeleting, setIsDeleting] = useState(false); const [pendingSharingChange, setPendingSharingChange] = useState(null); + const [isEditingDefaultRoles, setIsEditingDefaultRoles] = useState(false); return (
@@ -259,6 +283,50 @@ export const OrganizationSettingsPageView: FC< )} + {defaultRolesEnabled && onUpdateDefaultRoles && ( + + + Default Roles + {!defaultRolesEntitled && } + + } + description="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. +

+ )} +
+
+
+
+ )} + {!organization.is_default && ( setPendingSharingChange(null)} isLoading={isTogglingWorkspaceSharing} /> + + {onUpdateDefaultRoles && ( + setIsEditingDefaultRoles(false)} + onConfirm={async (roles) => { + await onUpdateDefaultRoles(roles); + setIsEditingDefaultRoles(false); + }} + isUpdating={Boolean(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)}
  • + ))} +
+ ); +}; From a05cecccdc43fb90d8bccfa31ad5302ed151b279 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Fri, 5 Jun 2026 17:03:25 +0000 Subject: [PATCH 2/3] feat(site): move Default Roles UI from Settings to Roles page Relocate the Default Roles section, its dialog, and the roles query to the organization Roles page. The selector sits above Custom Roles, shares the page-level roles query, and stays gated on the experiment plus the editSettings permission. --- .../CustomRolesPage/CustomRolesPage.tsx | 29 ++++ .../CustomRolesPageView.stories.tsx | 137 ++++++++++++++++++ .../CustomRolesPage/CustomRolesPageView.tsx | 135 ++++++++++++++++- .../DefaultRolesDialog.tsx | 0 .../OrganizationSettingsPage.tsx | 29 ---- .../OrganizationSettingsPageView.stories.tsx | 118 +-------------- .../OrganizationSettingsPageView.tsx | 105 -------------- 7 files changed, 300 insertions(+), 253 deletions(-) rename site/src/pages/OrganizationSettingsPage/{ => CustomRolesPage}/DefaultRolesDialog.tsx (100%) diff --git a/site/src/pages/OrganizationSettingsPage/CustomRolesPage/CustomRolesPage.tsx b/site/src/pages/OrganizationSettingsPage/CustomRolesPage/CustomRolesPage.tsx index 546408bb87d..03174f8bb79 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 db6556027eb..1a7834c9256 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,54 @@ 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; + /** + * Whether to render the Default Roles section. Gated on the + * minimum-implicit-member experiment at the page level. + */ + defaultRolesEnabled?: boolean; + /** + * Whether the deployment is entitled to multi-org features. The + * underlying PATCH endpoint is multi-org gated, so the edit button + * is disabled when this is false. + */ + 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 +83,15 @@ export const CustomRolesPageView: FC = ({ documentationLink={docs("/admin/users/groups-roles")} /> )} + {showDefaultRoles && onUpdateDefaultRoles && ( + + )}

Custom Roles

@@ -99,6 +136,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/DefaultRolesDialog.tsx b/site/src/pages/OrganizationSettingsPage/CustomRolesPage/DefaultRolesDialog.tsx similarity index 100% rename from site/src/pages/OrganizationSettingsPage/DefaultRolesDialog.tsx rename to site/src/pages/OrganizationSettingsPage/CustomRolesPage/DefaultRolesDialog.tsx diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationSettingsPage.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationSettingsPage.tsx index 335b9593447..3663d7e9268 100644 --- a/site/src/pages/OrganizationSettingsPage/OrganizationSettingsPage.tsx +++ b/site/src/pages/OrganizationSettingsPage/OrganizationSettingsPage.tsx @@ -9,10 +9,8 @@ import { updateOrganization, workspaceSharingSettings, } from "#/api/queries/organizations"; -import { organizationRoles } from "#/api/queries/roles"; import type { ShareableWorkspaceOwners } from "#/api/typesGenerated"; import { EmptyState } from "#/components/EmptyState/EmptyState"; -import { useDashboard } from "#/modules/dashboard/useDashboard"; import { useOrganizationSettings } from "#/modules/management/OrganizationSettingsLayout"; import { RequirePermission } from "#/modules/permissions/RequirePermission"; import { pageTitle } from "#/utils/page"; @@ -28,10 +26,6 @@ const OrganizationSettingsPage: FC = () => { const navigate = useNavigate(); const queryClient = useQueryClient(); const { organization, organizationPermissions } = useOrganizationSettings(); - const { experiments, entitlements } = useDashboard(); - const defaultRolesEnabled = experiments.includes("minimum-implicit-member"); - const defaultRolesEntitled = - entitlements.features.multiple_organizations.enabled; const updateOrganizationMutation = useMutation( updateOrganization(queryClient), @@ -49,11 +43,6 @@ const OrganizationSettingsPage: FC = () => { patchWorkspaceSharingSettings(organization?.id ?? "", queryClient), ); - const orgRolesQuery = useQuery({ - ...organizationRoles(organization?.name ?? ""), - enabled: defaultRolesEnabled && Boolean(organization), - }); - if (!organization) { return ; } @@ -137,24 +126,6 @@ const OrganizationSettingsPage: FC = () => { } onChangeShareableOwners={handleChangeShareableOwners} isTogglingWorkspaceSharing={patchSharingSettingsMutation.isPending} - defaultRolesEnabled={defaultRolesEnabled} - defaultRolesEntitled={defaultRolesEntitled} - availableOrgRoles={orgRolesQuery.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) }, - ); - } - }} /> ); diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationSettingsPageView.stories.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationSettingsPageView.stories.tsx index 06b0eb59ea9..828ed18940e 100644 --- a/site/src/pages/OrganizationSettingsPage/OrganizationSettingsPageView.stories.tsx +++ b/site/src/pages/OrganizationSettingsPage/OrganizationSettingsPageView.stories.tsx @@ -1,7 +1,6 @@ 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 { userEvent, within } from "storybook/test"; import { chromatic } from "#/testHelpers/chromatic"; import { MockDefaultOrganization, @@ -9,42 +8,6 @@ import { } from "#/testHelpers/entities"; import { OrganizationSettingsPageView } from "./OrganizationSettingsPageView"; -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 = { title: "pages/OrganizationSettingsPageView", component: OrganizationSettingsPageView, @@ -123,82 +86,3 @@ export const RestrictToServiceAccountsDialog: Story = { await user.click(radio); }, }; - -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 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/OrganizationSettingsPageView.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationSettingsPageView.tsx index 7c52fbe7345..f9e904581ae 100644 --- a/site/src/pages/OrganizationSettingsPage/OrganizationSettingsPageView.tsx +++ b/site/src/pages/OrganizationSettingsPage/OrganizationSettingsPageView.tsx @@ -4,14 +4,12 @@ import { type FC, useState } from "react"; import * as Yup from "yup"; import { isApiValidationError } from "#/api/errors"; import type { - AssignableRoles, Organization, ShareableWorkspaceOwners, UpdateOrganizationRequest, } from "#/api/typesGenerated"; import { Alert, AlertTitle } from "#/components/Alert/Alert"; import { ErrorAlert } from "#/components/Alert/ErrorAlert"; -import { PremiumBadge } from "#/components/Badges/Badges"; import { Button } from "#/components/Button/Button"; import { Checkbox } from "#/components/Checkbox/Checkbox"; import { DeleteDialog } from "#/components/Dialogs/DeleteDialog/DeleteDialog"; @@ -34,7 +32,6 @@ import { nameValidator, onChangeTrimmed, } from "#/utils/formUtils"; -import { DefaultRolesDialog } from "./DefaultRolesDialog"; import { DisableWorkspaceSharingDialog } from "./DisableWorkspaceSharingDialog"; import { HorizontalContainer, HorizontalSection } from "./Horizontal"; @@ -59,21 +56,6 @@ interface OrganizationSettingsPageViewProps { shareableWorkspaceOwners: ShareableWorkspaceOwners; onChangeShareableOwners: (value: ShareableWorkspaceOwners) => void; isTogglingWorkspaceSharing: boolean; - - /** - * Whether to render the Default Roles section. Gated on the - * minimum-implicit-member experiment at the page level. - */ - defaultRolesEnabled?: boolean; - /** - * Whether the deployment is entitled to multi-org features. The - * underlying PATCH endpoint is multi-org gated, so the edit button - * is disabled when this is false. - */ - defaultRolesEntitled?: boolean; - availableOrgRoles?: AssignableRoles[]; - onUpdateDefaultRoles?: (roles: string[]) => Promise; - isUpdatingDefaultRoles?: boolean; } export const OrganizationSettingsPageView: FC< @@ -87,11 +69,6 @@ export const OrganizationSettingsPageView: FC< shareableWorkspaceOwners, onChangeShareableOwners, isTogglingWorkspaceSharing, - defaultRolesEnabled, - defaultRolesEntitled, - availableOrgRoles, - onUpdateDefaultRoles, - isUpdatingDefaultRoles, }) => { const form = useFormik({ initialValues: { @@ -109,7 +86,6 @@ export const OrganizationSettingsPageView: FC< const [isDeleting, setIsDeleting] = useState(false); const [pendingSharingChange, setPendingSharingChange] = useState(null); - const [isEditingDefaultRoles, setIsEditingDefaultRoles] = useState(false); return (
@@ -283,50 +259,6 @@ export const OrganizationSettingsPageView: FC< )} - {defaultRolesEnabled && onUpdateDefaultRoles && ( - - - Default Roles - {!defaultRolesEntitled && } - - } - description="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. -

- )} -
-
-
-
- )} - {!organization.is_default && ( setPendingSharingChange(null)} isLoading={isTogglingWorkspaceSharing} /> - - {onUpdateDefaultRoles && ( - setIsEditingDefaultRoles(false)} - onConfirm={async (roles) => { - await onUpdateDefaultRoles(roles); - setIsEditingDefaultRoles(false); - }} - isUpdating={Boolean(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)}
  • - ))} -
- ); -}; From 3a0ec375bd2546381795925b6a7cee72400d5613 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Fri, 5 Jun 2026 18:05:18 +0000 Subject: [PATCH 3/3] fix(site): drop redundant default-roles prop JSDoc The comments either named an experiment that will go stale when `minimum-implicit-member` is promoted, or described implementation rather than behavior. Prop names already convey intent. --- .../CustomRolesPage/CustomRolesPageView.tsx | 9 --------- 1 file changed, 9 deletions(-) diff --git a/site/src/pages/OrganizationSettingsPage/CustomRolesPage/CustomRolesPageView.tsx b/site/src/pages/OrganizationSettingsPage/CustomRolesPage/CustomRolesPageView.tsx index 1a7834c9256..2ebe55b3aa1 100644 --- a/site/src/pages/OrganizationSettingsPage/CustomRolesPage/CustomRolesPageView.tsx +++ b/site/src/pages/OrganizationSettingsPage/CustomRolesPage/CustomRolesPageView.tsx @@ -39,16 +39,7 @@ interface CustomRolesPageViewProps { canDeleteOrgRole: boolean; canEditDefaultRoles: boolean; isCustomRolesEnabled: boolean; - /** - * Whether to render the Default Roles section. Gated on the - * minimum-implicit-member experiment at the page level. - */ defaultRolesEnabled?: boolean; - /** - * Whether the deployment is entitled to multi-org features. The - * underlying PATCH endpoint is multi-org gated, so the edit button - * is disabled when this is false. - */ defaultRolesEntitled?: boolean; availableOrgRoles?: AssignableRoles[]; onUpdateDefaultRoles?: (roles: string[]) => Promise;