Browse Source

feat(roles): close team membership to non-org owners (#45163)

Cathy Teng 2 years ago
parent
commit
9eb9eb51ea

+ 1 - 0
fixtures/js-stubs/team.js

@@ -3,6 +3,7 @@ export function Team(params = {}) {
     id: '1',
     slug: 'team-slug',
     name: 'Team Name',
+    orgRole: null,
     isMember: true,
     memberCount: 0,
     flags: {

+ 3 - 1
static/app/data/forms/teamSettingsFields.tsx

@@ -40,7 +40,9 @@ const formGroups: JsonFormObject[] = [
         },
         required: false,
         label: t('Organization Role'),
-        help: t('The organization role that team members will have access to'),
+        help: t(
+          'Organization owners can bulk assign an org-role for all the members in this team'
+        ),
         disabled: ({access, idpProvisioned}) =>
           !access.has('org:admin') || idpProvisioned,
         visible: ({hasOrgRoleFlag}) => hasOrgRoleFlag,

+ 56 - 29
static/app/views/settings/components/teamSelect.tsx

@@ -1,6 +1,7 @@
 import React from 'react';
 import styled from '@emotion/styled';
 import debounce from 'lodash/debounce';
+import startCase from 'lodash/startCase';
 
 import {Button} from 'sentry/components/button';
 import Confirm from 'sentry/components/confirm';
@@ -25,6 +26,7 @@ import {
   hasOrgRoleOverwrite,
   RoleOverwritePanelAlert,
 } from 'sentry/views/settings/organizationTeams/roleOverwriteWarning';
+import {getButtonHelpText} from 'sentry/views/settings/organizationTeams/utils';
 
 type Props = {
   /**
@@ -50,6 +52,11 @@ type Props = {
    * if empty no confirm will be displayed.
    */
   confirmLastTeamRemoveMessage?: string;
+  /**
+   * Allow adding to teams with org role
+   * if the user is an org owner
+   */
+  isOrgOwner?: boolean;
   /**
    * Used to determine whether we should show a loading state while waiting for teams
    */
@@ -78,6 +85,7 @@ type Props = {
 
 function TeamSelect({
   disabled,
+  isOrgOwner,
   loadingTeams,
   enforceIdpProvisioned,
   menuHeader,
@@ -166,6 +174,7 @@ function TeamSelect({
                 confirmMessage={confirmMessage}
                 organization={organization}
                 team={team}
+                isOrgOwner={isOrgOwner ?? false}
                 selectedOrgRole={effectiveOrgRole}
                 selectedTeamRole={r.role}
                 onChangeTeamRole={onChangeTeamRole}
@@ -180,26 +189,31 @@ function TeamSelect({
   // Only show options that aren't selected in the dropdown
   const options = teams
     .filter(team => !slugsToFilter.some(slug => slug === team.slug))
-    .map((team, index) => ({
-      index,
-      value: team.slug,
-      searchKey: team.slug,
-      label: () => {
-        if (enforceIdpProvisioned && team.flags['idp:provisioned']) {
-          return (
-            <Tooltip
-              title={t(
-                "Membership to this team is managed through your organization's identity provider."
-              )}
-            >
-              <DropdownTeamBadgeDisabled avatarSize={18} team={team} />
-            </Tooltip>
-          );
-        }
-        return <DropdownTeamBadge avatarSize={18} team={team} />;
-      },
-      disabled: enforceIdpProvisioned && team.flags['idp:provisioned'],
-    }));
+    .map((team, index) => {
+      const isIdpProvisioned = enforceIdpProvisioned && team.flags['idp:provisioned'];
+
+      return {
+        index,
+        value: team.slug,
+        searchKey: team.slug,
+        label: () => {
+          // TODO(team-roles): team admins can also manage membership
+          const isPermissionGroup = team.orgRole !== null && !isOrgOwner;
+          const buttonHelpText = getButtonHelpText(isIdpProvisioned, isPermissionGroup);
+
+          if (isIdpProvisioned || isPermissionGroup) {
+            return (
+              <Tooltip title={buttonHelpText}>
+                <DropdownTeamBadgeDisabled avatarSize={18} team={team} />
+              </Tooltip>
+            );
+          }
+
+          return <DropdownTeamBadge avatarSize={18} team={team} />;
+        },
+        disabled: disabled || isIdpProvisioned || (team.orgRole !== null && !isOrgOwner),
+      };
+    });
 
   return (
     <Panel>
@@ -273,6 +287,7 @@ const ProjectTeamRow = ({
 
 type MemberTeamRowProps = {
   enforceIdpProvisioned: boolean;
+  isOrgOwner: boolean;
   onChangeTeamRole: Props['onChangeTeamRole'];
   selectedOrgRole: Member['orgRole'];
   selectedTeamRole: Member['teamRoles'][0]['role'];
@@ -285,6 +300,7 @@ const MemberTeamRow = ({
   selectedTeamRole,
   onRemoveTeam,
   onChangeTeamRole,
+  isOrgOwner,
   disabled,
   confirmMessage,
   enforceIdpProvisioned,
@@ -300,12 +316,22 @@ const MemberTeamRow = ({
     ? teamRoleList[1] // set as team admin
     : teamRoleList.find(r => r.id === selectedTeamRole) || teamRoleList[0];
 
+  const orgRoleFromTeam = team.orgRole ? `${startCase(team.orgRole)} Team` : null;
+
+  const isIdpProvisioned = enforceIdpProvisioned && team.flags['idp:provisioned'];
+  const isPermissionGroup = team.orgRole !== null && !isOrgOwner;
+  const isRemoveDisabled = disabled || isIdpProvisioned || isPermissionGroup;
+
+  const buttonHelpText = getButtonHelpText(isIdpProvisioned, isPermissionGroup);
+
   return (
     <TeamPanelItem data-test-id="team-row-for-member">
       <StyledLink to={`/settings/${organization.slug}/teams/${team.slug}/`}>
         <TeamBadge team={team} />
       </StyledLink>
 
+      <TeamOrgRole>{orgRoleFromTeam}</TeamOrgRole>
+
       {organization.features.includes('team-roles') && onChangeTeamRole && (
         <React.Fragment>
           <StyledRoleSelectControl
@@ -323,19 +349,13 @@ const MemberTeamRow = ({
         message={confirmMessage}
         bypass={!confirmMessage}
         onConfirm={() => onRemoveTeam(team.slug)}
-        disabled={disabled || (enforceIdpProvisioned && team.flags['idp:provisioned'])}
+        disabled={isRemoveDisabled}
       >
         <Button
           size="xs"
           icon={<IconSubtract isCircled size="xs" />}
-          disabled={disabled || (enforceIdpProvisioned && team.flags['idp:provisioned'])}
-          title={
-            enforceIdpProvisioned && team.flags['idp:provisioned']
-              ? t(
-                  "Membership to this team is managed through your organization's identity provider."
-                )
-              : undefined
-          }
+          disabled={isRemoveDisabled}
+          title={buttonHelpText}
         >
           {t('Remove')}
         </Button>
@@ -364,7 +384,14 @@ const TeamPanelItem = styled(PanelItem)`
 `;
 
 const StyledLink = styled(Link)`
+  flex-grow: 4;
+`;
+
+const TeamOrgRole = styled('div')`
+  min-width: 90px;
   flex-grow: 1;
+  display: flex;
+  justify-content: center;
 `;
 
 const StyledRoleSelectControl = styled(RoleSelectControl)`

+ 69 - 11
static/app/views/settings/organizationMembers/organizationMemberDetail.spec.jsx

@@ -23,7 +23,7 @@ describe('OrganizationMemberDetail', function () {
 
   const team = TestStubs.Team();
   const idpTeam = TestStubs.Team({
-    id: '4',
+    id: '3',
     slug: 'idp-member-team',
     name: 'Idp Member Team',
     isMember: true,
@@ -32,6 +32,13 @@ describe('OrganizationMemberDetail', function () {
     },
   });
   const managerTeam = TestStubs.Team({id: '5', orgRole: 'manager', slug: 'manager-team'});
+  const otherManagerTeam = TestStubs.Team({
+    id: '4',
+    slug: 'org-role-team',
+    name: 'Org Role Team',
+    isMember: true,
+    orgRole: 'manager',
+  });
   const teams = [
     team,
     TestStubs.Team({
@@ -40,17 +47,9 @@ describe('OrganizationMemberDetail', function () {
       name: 'New Team',
       isMember: false,
     }),
-    TestStubs.Team({
-      id: '3',
-      slug: 'idp-team',
-      name: 'Idp Team',
-      isMember: false,
-      flags: {
-        'idp:provisioned': true,
-      },
-    }),
     idpTeam,
     managerTeam,
+    otherManagerTeam,
   ];
 
   const teamAssignment = {
@@ -97,6 +96,23 @@ describe('OrganizationMemberDetail', function () {
       },
     ],
   });
+  const managerTeamMember = TestStubs.Member({
+    id: 5,
+    roles: TestStubs.OrgRoleList(),
+    dateCreated: new Date(),
+    teams: [otherManagerTeam.slug],
+    teamRoles: [
+      {
+        teamSlug: otherManagerTeam.slug,
+        role: null,
+      },
+    ],
+  });
+  const managerMember = TestStubs.Member({
+    id: 6,
+    roles: TestStubs.OrgRoleList(),
+    role: 'manager',
+  });
 
   beforeAll(() => {
     TeamStore.loadInitialData(teams);
@@ -129,6 +145,14 @@ describe('OrganizationMemberDetail', function () {
         url: `/organizations/${organization.slug}/members/${idpTeamMember.id}/`,
         body: idpTeamMember,
       });
+      MockApiClient.addMockResponse({
+        url: `/organizations/${organization.slug}/members/${managerTeamMember.id}/`,
+        body: managerTeamMember,
+      });
+      MockApiClient.addMockResponse({
+        url: `/organizations/${organization.slug}/members/${managerMember.id}/`,
+        body: managerMember,
+      });
       MockApiClient.addMockResponse({
         url: `/organizations/${organization.slug}/teams/`,
         body: teams,
@@ -190,6 +214,40 @@ describe('OrganizationMemberDetail', function () {
       expect(screen.getByRole('button', {name: 'Remove'})).toBeDisabled();
     });
 
+    it('cannot leave org role team if missing org:admin', function () {
+      organization = TestStubs.Organization({
+        teams,
+        features: ['team-roles'],
+        access: [],
+      });
+      routerContext = TestStubs.routerContext([{organization}]);
+      render(<OrganizationMemberDetail params={{memberId: managerTeamMember.id}} />, {
+        context: routerContext,
+      });
+      expect(screen.getByText('Manager Team')).toBeInTheDocument();
+      expect(screen.getByRole('button', {name: 'Remove'})).toBeDisabled();
+    });
+
+    it('cannot join org role team if missing org:admin', async function () {
+      organization = TestStubs.Organization({
+        teams,
+        features: ['team-roles'],
+        access: ['org:write'],
+      });
+      routerContext = TestStubs.routerContext([{organization}]);
+      render(<OrganizationMemberDetail params={{memberId: managerMember.id}} />, {
+        context: routerContext,
+      });
+
+      await userEvent.click(screen.getByText('Add Team'));
+      await userEvent.hover(screen.queryByText('#org-role-team'));
+      expect(
+        await screen.findByText(
+          'Membership to a team with an organization role is managed by org owners.'
+        )
+      ).toBeInTheDocument();
+    });
+
     it('joins a team and assign a team-role', async function () {
       render(<OrganizationMemberDetail params={{memberId: member.id}} />, {
         context: routerContext,
@@ -230,7 +288,7 @@ describe('OrganizationMemberDetail', function () {
       });
 
       await userEvent.click(screen.getByText('Add Team'));
-      await userEvent.hover(screen.queryByText('#idp-team'));
+      await userEvent.hover(screen.queryByText('#idp-member-team'));
       expect(
         await screen.findByText(
           "Membership to this team is managed through your organization's identity provider."

+ 3 - 0
static/app/views/settings/organizationMembers/organizationMemberDetail.tsx

@@ -273,6 +273,8 @@ class OrganizationMemberDetail extends AsyncView<Props, State> {
 
     const {access, features, orgRoleList} = organization;
     const canEdit = access.includes('org:write') && !this.memberDeactivated;
+    // org:admin is a unique scope that only org owners have
+    const isOrgOwner = access.includes('org:admin');
     const hasTeamRoles = features.includes('team-roles');
 
     const {email, expired, pending, invite_link: inviteLink} = member;
@@ -402,6 +404,7 @@ class OrganizationMemberDetail extends AsyncView<Props, State> {
             <TeamSelect
               enforceIdpProvisioned
               disabled={!canEdit}
+              isOrgOwner={isOrgOwner}
               organization={organization}
               selectedOrgRole={orgRole}
               selectedTeamRoles={teamRoles}

+ 35 - 11
static/app/views/settings/organizationTeams/allTeamsRow.tsx

@@ -1,5 +1,6 @@
 import {Component} from 'react';
 import styled from '@emotion/styled';
+import startCase from 'lodash/startCase';
 
 import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator';
 import {fetchOrganizationDetails} from 'sentry/actionCreators/organizations';
@@ -14,6 +15,7 @@ import TeamStore from 'sentry/stores/teamStore';
 import {space} from 'sentry/styles/space';
 import {Organization, Team} from 'sentry/types';
 import withApi from 'sentry/utils/withApi';
+import {getButtonHelpText} from 'sentry/views/settings/organizationTeams/utils';
 
 type Props = {
   api: Client;
@@ -179,12 +181,17 @@ class AllTeamsRow extends Component<Props, State> {
 
   render() {
     const {team, openMembership, organization} = this.props;
+    const {access} = organization;
     const urlPrefix = `/settings/${organization.slug}/teams/`;
-    const buttonHelpText = team.flags['idp:provisioned']
-      ? t(
-          "Membership to this team is managed through your organization's identity provider."
-        )
-      : undefined;
+    const canEditTeam = access.includes('org:write') || access.includes('team:admin');
+
+    // TODO(team-roles): team admins can also manage membership
+    // org:admin is a unique scope that only org owners have
+    const isOrgOwner = access.includes('org:admin');
+    const isPermissionGroup = (team.orgRole && (!canEditTeam || !isOrgOwner)) as boolean;
+    const isIdpProvisioned = team.flags['idp:provisioned'];
+
+    const buttonHelpText = getButtonHelpText(isIdpProvisioned, isPermissionGroup);
 
     const display = (
       <IdBadge
@@ -198,7 +205,10 @@ class AllTeamsRow extends Component<Props, State> {
     // for your role + org open membership
     const canViewTeam = team.hasAccess;
 
-    const idpProvisioned = team.flags['idp:provisioned'];
+    const orgRoleFromTeam = team.orgRole ? `${startCase(team.orgRole)} Team` : null;
+    const isHidden = orgRoleFromTeam === null && this.getTeamRoleName() === null;
+    // TODO(team-roles): team admins can also manage membership
+    const isDisabled = isIdpProvisioned || isPermissionGroup;
 
     return (
       <TeamPanelItem>
@@ -211,7 +221,8 @@ class AllTeamsRow extends Component<Props, State> {
             display
           )}
         </div>
-        <div>{this.getTeamRoleName()}</div>
+        <DisplayRole isHidden={isHidden}>{orgRoleFromTeam}</DisplayRole>
+        <DisplayRole isHidden={isHidden}>{this.getTeamRoleName()}</DisplayRole>
         <div>
           {this.state.loading ? (
             <Button size="sm" disabled>
@@ -221,7 +232,7 @@ class AllTeamsRow extends Component<Props, State> {
             <Button
               size="sm"
               onClick={this.handleLeaveTeam}
-              disabled={idpProvisioned}
+              disabled={isDisabled}
               title={buttonHelpText}
             >
               {t('Leave Team')}
@@ -240,7 +251,7 @@ class AllTeamsRow extends Component<Props, State> {
             <Button
               size="sm"
               onClick={this.handleJoinTeam}
-              disabled={idpProvisioned}
+              disabled={isDisabled}
               title={buttonHelpText}
             >
               {t('Join Team')}
@@ -249,7 +260,7 @@ class AllTeamsRow extends Component<Props, State> {
             <Button
               size="sm"
               onClick={this.handleRequestAccess}
-              disabled={idpProvisioned}
+              disabled={isDisabled}
               title={buttonHelpText}
             >
               {t('Request Access')}
@@ -278,11 +289,24 @@ export default withApi(AllTeamsRow);
 
 const TeamPanelItem = styled(PanelItem)`
   display: grid;
-  grid-template-columns: minmax(150px, 4fr) minmax(90px, 1fr) min-content;
+  grid-template-columns: minmax(150px, 4fr) min-content;
+  grid-template-rows: auto min-content;
   gap: ${space(2)};
   align-items: center;
 
   > div:last-child {
     margin-left: auto;
   }
+
+  @media (min-width: ${p => p.theme.breakpoints.small}) {
+    grid-template-columns: minmax(150px, 3fr) minmax(90px, 1fr) minmax(90px, 1fr) min-content;
+    grid-template-rows: auto;
+    > div:empty {
+      display: block !important;
+    }
+  }
+`;
+
+const DisplayRole = styled('div')<{isHidden: boolean}>`
+  display: ${props => (props.isHidden ? 'none' : 'block')};
 `;

+ 42 - 1
static/app/views/settings/organizationTeams/teamMembers.spec.jsx

@@ -400,7 +400,47 @@ describe('TeamMembers', function () {
       body: members,
     });
     Client.addMockResponse({
-      url: `/teams/${organization.slug}/${team.slug}/`,
+      url: `/teams/${organization.slug}/${team2.slug}/`,
+      method: 'GET',
+      body: team2,
+    });
+
+    render(
+      <TeamMembers
+        params={{orgId: organization.slug, teamId: team2.slug}}
+        organization={organization}
+        team={team2}
+      />
+    );
+
+    waitFor(() => {
+      expect(screen.findByRole('button', {name: 'Add Member'})).toBeDisabled();
+      expect(screen.findByRole('button', {name: 'Remove'})).toBeDisabled();
+    });
+  });
+
+  it('cannot add or remove members or leave if team has org role and no access', function () {
+    const team2 = TestStubs.Team({orgRole: 'manager'});
+
+    const me = TestStubs.Member({
+      id: '123',
+      email: 'foo@example.com',
+      role: 'member',
+    });
+
+    Client.clearMockResponses();
+    Client.addMockResponse({
+      url: `/organizations/${organization.slug}/members/`,
+      method: 'GET',
+      body: [...members, me],
+    });
+    Client.addMockResponse({
+      url: `/teams/${organization.slug}/${team2.slug}/members/`,
+      method: 'GET',
+      body: members,
+    });
+    Client.addMockResponse({
+      url: `/teams/${organization.slug}/${team2.slug}/`,
       method: 'GET',
       body: team2,
     });
@@ -416,6 +456,7 @@ describe('TeamMembers', function () {
     waitFor(() => {
       expect(screen.findByRole('button', {name: 'Add Member'})).toBeDisabled();
       expect(screen.findByRole('button', {name: 'Remove'})).toBeDisabled();
+      expect(screen.findByRole('button', {name: 'Leave'})).toBeDisabled();
     });
   });
 });

+ 14 - 7
static/app/views/settings/organizationTeams/teamMembers.tsx

@@ -28,8 +28,7 @@ import withApi from 'sentry/utils/withApi';
 import withConfig from 'sentry/utils/withConfig';
 import withOrganization from 'sentry/utils/withOrganization';
 import AsyncView from 'sentry/views/asyncView';
-
-import TeamMembersRow from './teamMembersRow';
+import TeamMembersRow from 'sentry/views/settings/organizationTeams/teamMembersRow';
 
 type RouteParams = {
   teamId: string;
@@ -209,7 +208,7 @@ class TeamMembers extends AsyncView<Props, State> {
     this.debouncedFetchMembersRequest(e.target.value);
   };
 
-  renderDropdown(hasWriteAccess: boolean) {
+  renderDropdown(hasWriteAccess: boolean, isOrgOwner: boolean) {
     const {organization, params, team} = this.props;
     const {orgMembers} = this.state;
     const existingMembers = new Set(this.state.teamMembers.map(member => member.id));
@@ -219,6 +218,9 @@ class TeamMembers extends AsyncView<Props, State> {
     const hasOpenMembership = !!organization?.openMembership;
     const canAddMembers = hasOpenMembership || hasWriteAccess;
 
+    const isDropdownDisabled =
+      team.flags['idp:provisioned'] || (team.orgRole !== null && !isOrgOwner);
+
     const items = (orgMembers || [])
       .filter(m => !existingMembers.has(m.id))
       .map(m => ({
@@ -264,14 +266,14 @@ class TeamMembers extends AsyncView<Props, State> {
         onChange={this.handleMemberFilterChange}
         busy={this.state.dropdownBusy}
         onClose={() => this.debouncedFetchMembersRequest('')}
-        disabled={team.flags['idp:provisioned']}
+        disabled={isDropdownDisabled}
       >
         {({isOpen}) => (
           <DropdownButton
             isOpen={isOpen}
             size="xs"
             data-test-id="add-member"
-            disabled={team.flags['idp:provisioned']}
+            disabled={isDropdownDisabled}
           >
             {t('Add Member')}
           </DropdownButton>
@@ -294,13 +296,17 @@ class TeamMembers extends AsyncView<Props, State> {
     const {access} = organization;
     const hasWriteAccess = access.includes('org:write') || access.includes('team:admin');
 
+    // TODO(team-roles): team admins can also manage membership
+    // org:admin is a unique scope that only org owners have
+    const isOrgOwner = access.includes('org:admin');
+
     return (
       <Fragment>
         <Panel>
           <PanelHeader hasButtons>
             <div>{t('Members')}</div>
             <div style={{textTransform: 'none'}}>
-              {this.renderDropdown(hasWriteAccess)}
+              {this.renderDropdown(hasWriteAccess, isOrgOwner)}
             </div>
           </PanelHeader>
           {this.state.teamMembers.length ? (
@@ -309,9 +315,10 @@ class TeamMembers extends AsyncView<Props, State> {
                 <TeamMembersRow
                   key={member.id}
                   hasWriteAccess={hasWriteAccess}
+                  isOrgOwner={isOrgOwner}
+                  team={team}
                   member={member}
                   organization={organization}
-                  team={team}
                   removeMember={this.removeTeamMember}
                   updateMemberRole={this.updateTeamMemberRole}
                   user={config.user}

+ 14 - 6
static/app/views/settings/organizationTeams/teamMembersRow.tsx

@@ -13,9 +13,11 @@ import {
   hasOrgRoleOverwrite,
   RoleOverwriteIcon,
 } from 'sentry/views/settings/organizationTeams/roleOverwriteWarning';
+import {getButtonHelpText} from 'sentry/views/settings/organizationTeams/utils';
 
 const TeamMembersRow = (props: {
   hasWriteAccess: boolean;
+  isOrgOwner: boolean;
   member: TeamMember;
   organization: Organization;
   removeMember: (member: Member) => void;
@@ -29,6 +31,7 @@ const TeamMembersRow = (props: {
     member,
     user,
     hasWriteAccess,
+    isOrgOwner,
     removeMember,
     updateMemberRole,
   } = props;
@@ -50,6 +53,8 @@ const TeamMembersRow = (props: {
       <div>
         <RemoveButton
           hasWriteAccess={hasWriteAccess}
+          hasOrgRoleFromTeam={team.orgRole !== null}
+          isOrgOwner={isOrgOwner}
           onClick={() => removeMember(member)}
           member={member}
           user={user}
@@ -117,20 +122,26 @@ const TeamRoleSelect = (props: {
 };
 
 const RemoveButton = (props: {
+  hasOrgRoleFromTeam: boolean;
   hasWriteAccess: boolean;
+  isOrgOwner: boolean;
   member: TeamMember;
   onClick: () => void;
   user: User;
 }) => {
-  const {member, user, hasWriteAccess, onClick} = props;
+  const {member, user, hasWriteAccess, isOrgOwner, hasOrgRoleFromTeam, onClick} = props;
 
   const isSelf = member.email === user.email;
   const canRemoveMember = hasWriteAccess || isSelf;
   if (!canRemoveMember) {
     return null;
   }
+  const isIdpProvisioned = member.flags['idp:provisioned'];
+  const isPermissionGroup = hasOrgRoleFromTeam && !isOrgOwner;
 
-  if (member.flags['idp:provisioned']) {
+  const buttonHelpText = getButtonHelpText(isIdpProvisioned, isPermissionGroup);
+
+  if (isIdpProvisioned || isPermissionGroup) {
     return (
       <Button
         size="xs"
@@ -138,15 +149,12 @@ const RemoveButton = (props: {
         icon={<IconSubtract size="xs" isCircled />}
         onClick={onClick}
         aria-label={t('Remove')}
-        title={t(
-          "Membership to this team is managed through your organization's identity provider."
-        )}
+        title={buttonHelpText}
       >
         {t('Remove')}
       </Button>
     );
   }
-
   return (
     <Button
       size="xs"

+ 13 - 0
static/app/views/settings/organizationTeams/utils.tsx

@@ -0,0 +1,13 @@
+import {t} from 'sentry/locale';
+
+export function getButtonHelpText(isIdpProvisioned: boolean, isPermissionGroup: boolean) {
+  if (isIdpProvisioned) {
+    return t(
+      "Membership to this team is managed through your organization's identity provider."
+    );
+  }
+  if (isPermissionGroup) {
+    return t('Membership to a team with an organization role is managed by org owners.');
+  }
+  return undefined;
+}