teamMembers.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419
  1. import {Fragment} from 'react';
  2. import type {RouteComponentProps} from 'react-router';
  3. import styled from '@emotion/styled';
  4. import debounce from 'lodash/debounce';
  5. import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator';
  6. import {
  7. openInviteMembersModal,
  8. openTeamAccessRequestModal,
  9. } from 'sentry/actionCreators/modal';
  10. import {joinTeam, leaveTeam} from 'sentry/actionCreators/teams';
  11. import type {Client} from 'sentry/api';
  12. import {hasEveryAccess} from 'sentry/components/acl/access';
  13. import UserAvatar from 'sentry/components/avatar/userAvatar';
  14. import DropdownAutoComplete from 'sentry/components/dropdownAutoComplete';
  15. import type {Item} from 'sentry/components/dropdownAutoComplete/types';
  16. import DropdownButton from 'sentry/components/dropdownButton';
  17. import EmptyMessage from 'sentry/components/emptyMessage';
  18. import Link from 'sentry/components/links/link';
  19. import LoadingError from 'sentry/components/loadingError';
  20. import LoadingIndicator from 'sentry/components/loadingIndicator';
  21. import Pagination from 'sentry/components/pagination';
  22. import Panel from 'sentry/components/panels/panel';
  23. import PanelHeader from 'sentry/components/panels/panelHeader';
  24. import {TeamRoleColumnLabel} from 'sentry/components/teamRoleUtils';
  25. import {IconUser} from 'sentry/icons';
  26. import {t} from 'sentry/locale';
  27. import {space} from 'sentry/styles/space';
  28. import type {Config, Member, Organization, Team, TeamMember} from 'sentry/types';
  29. import withApi from 'sentry/utils/withApi';
  30. import withConfig from 'sentry/utils/withConfig';
  31. import withOrganization from 'sentry/utils/withOrganization';
  32. import type {AsyncViewState} from 'sentry/views/deprecatedAsyncView';
  33. import DeprecatedAsyncView from 'sentry/views/deprecatedAsyncView';
  34. import TextBlock from 'sentry/views/settings/components/text/textBlock';
  35. import TeamMembersRow, {
  36. GRID_TEMPLATE,
  37. } from 'sentry/views/settings/organizationTeams/teamMembersRow';
  38. import PermissionAlert from 'sentry/views/settings/project/permissionAlert';
  39. import {getButtonHelpText} from './utils';
  40. type RouteParams = {
  41. teamId: string;
  42. };
  43. interface Props extends RouteComponentProps<RouteParams, {}> {
  44. api: Client;
  45. config: Config;
  46. organization: Organization;
  47. team: Team;
  48. }
  49. interface State extends AsyncViewState {
  50. dropdownBusy: boolean;
  51. error: boolean;
  52. orgMembers: Member[];
  53. teamMembers: TeamMember[];
  54. }
  55. class TeamMembers extends DeprecatedAsyncView<Props, State> {
  56. getDefaultState() {
  57. return {
  58. ...super.getDefaultState(),
  59. error: false,
  60. dropdownBusy: false,
  61. teamMembers: [],
  62. orgMembers: [],
  63. };
  64. }
  65. componentDidMount() {
  66. super.componentDidMount();
  67. // Initialize "add member" dropdown with data
  68. this.fetchMembersRequest('');
  69. }
  70. debouncedFetchMembersRequest = debounce(
  71. (query: string) =>
  72. this.setState({dropdownBusy: true}, () => this.fetchMembersRequest(query)),
  73. 200
  74. );
  75. fetchMembersRequest = async (query: string) => {
  76. const {organization, api} = this.props;
  77. try {
  78. const data = await api.requestPromise(
  79. `/organizations/${organization.slug}/members/`,
  80. {
  81. query: {query},
  82. }
  83. );
  84. this.setState({
  85. orgMembers: data,
  86. dropdownBusy: false,
  87. });
  88. } catch (_err) {
  89. addErrorMessage(t('Unable to load organization members.'), {
  90. duration: 2000,
  91. });
  92. this.setState({
  93. dropdownBusy: false,
  94. });
  95. }
  96. };
  97. getEndpoints(): ReturnType<DeprecatedAsyncView['getEndpoints']> {
  98. const {organization, params} = this.props;
  99. return [
  100. [
  101. 'teamMembers',
  102. `/teams/${organization.slug}/${params.teamId}/members/`,
  103. {},
  104. {paginate: true},
  105. ],
  106. ];
  107. }
  108. addTeamMember = (selection: Item) => {
  109. const {organization, params} = this.props;
  110. const {orgMembers, teamMembers} = this.state;
  111. // Reset members list after adding member to team
  112. this.debouncedFetchMembersRequest('');
  113. joinTeam(
  114. this.props.api,
  115. {
  116. orgId: organization.slug,
  117. teamId: params.teamId,
  118. memberId: selection.value,
  119. },
  120. {
  121. success: () => {
  122. const orgMember = orgMembers.find(member => member.id === selection.value);
  123. if (orgMember === undefined) {
  124. return;
  125. }
  126. this.setState({
  127. error: false,
  128. teamMembers: teamMembers.concat([orgMember as TeamMember]),
  129. });
  130. addSuccessMessage(t('Successfully added member to team.'));
  131. },
  132. error: resp => {
  133. const errorMessage =
  134. resp?.responseJSON?.detail || t('Unable to add team member.');
  135. addErrorMessage(errorMessage);
  136. },
  137. }
  138. );
  139. };
  140. removeTeamMember = (member: Member) => {
  141. const {organization, params} = this.props;
  142. const {teamMembers} = this.state;
  143. leaveTeam(
  144. this.props.api,
  145. {
  146. orgId: organization.slug,
  147. teamId: params.teamId,
  148. memberId: member.id,
  149. },
  150. {
  151. success: () => {
  152. this.setState({
  153. teamMembers: teamMembers.filter(m => m.id !== member.id),
  154. });
  155. addSuccessMessage(t('Successfully removed member from team.'));
  156. },
  157. error: () =>
  158. addErrorMessage(
  159. t('There was an error while trying to remove a member from the team.')
  160. ),
  161. }
  162. );
  163. };
  164. updateTeamMemberRole = (member: Member, newRole: string) => {
  165. const {organization} = this.props;
  166. const {teamId} = this.props.params;
  167. const endpoint = `/organizations/${organization.slug}/members/${member.id}/teams/${teamId}/`;
  168. this.props.api.request(endpoint, {
  169. method: 'PUT',
  170. data: {teamRole: newRole},
  171. success: data => {
  172. const teamMembers: any = [...this.state.teamMembers];
  173. const i = teamMembers.findIndex(m => m.id === member.id);
  174. teamMembers[i] = {
  175. ...member,
  176. teamRole: data.teamRole,
  177. };
  178. this.setState({teamMembers});
  179. addSuccessMessage(t('Successfully changed role for team member.'));
  180. },
  181. error: () => {
  182. addErrorMessage(
  183. t('There was an error while trying to change the roles for a team member.')
  184. );
  185. },
  186. });
  187. };
  188. /**
  189. * We perform an API request to support orgs with > 100 members (since that's the max API returns)
  190. *
  191. * @param {Event} e React Event when member filter input changes
  192. */
  193. handleMemberFilterChange = (e: React.ChangeEvent<HTMLInputElement>) => {
  194. this.setState({dropdownBusy: true});
  195. this.debouncedFetchMembersRequest(e.target.value);
  196. };
  197. renderDropdown(isTeamAdmin: boolean) {
  198. const {organization, params, team} = this.props;
  199. const {orgMembers} = this.state;
  200. const existingMembers = new Set(this.state.teamMembers.map(member => member.id));
  201. // members can add other members to a team if the `Open Membership` setting is enabled
  202. // otherwise, `org:write` or `team:admin` permissions are required
  203. const hasOpenMembership = !!organization?.openMembership;
  204. const canAddMembers = hasOpenMembership || isTeamAdmin;
  205. const isDropdownDisabled = team.flags['idp:provisioned'];
  206. const items = (orgMembers || [])
  207. .filter(m => !existingMembers.has(m.id))
  208. .map(m => ({
  209. searchKey: `${m.name} ${m.email}`,
  210. value: m.id,
  211. label: (
  212. <StyledUserListElement>
  213. <StyledAvatar user={m} size={24} className="avatar" />
  214. <StyledNameOrEmail>{m.name || m.email}</StyledNameOrEmail>
  215. </StyledUserListElement>
  216. ),
  217. }));
  218. const menuHeader = (
  219. <StyledMembersLabel>
  220. {t('Members')}
  221. <StyledCreateMemberLink
  222. to=""
  223. onClick={() => openInviteMembersModal({source: 'teams'})}
  224. data-test-id="invite-member"
  225. >
  226. {t('Invite Member')}
  227. </StyledCreateMemberLink>
  228. </StyledMembersLabel>
  229. );
  230. return (
  231. <DropdownAutoComplete
  232. closeOnSelect={false}
  233. items={items}
  234. alignMenu="right"
  235. onSelect={
  236. canAddMembers
  237. ? this.addTeamMember
  238. : selection =>
  239. openTeamAccessRequestModal({
  240. teamId: params.teamId,
  241. orgId: organization.slug,
  242. memberId: selection.value,
  243. })
  244. }
  245. menuHeader={menuHeader}
  246. emptyMessage={t('No members')}
  247. onChange={this.handleMemberFilterChange}
  248. busy={this.state.dropdownBusy}
  249. onClose={() => this.debouncedFetchMembersRequest('')}
  250. disabled={isDropdownDisabled}
  251. data-test-id="add-member-menu"
  252. >
  253. {({isOpen}) => (
  254. <DropdownButton
  255. isOpen={isOpen}
  256. size="xs"
  257. data-test-id="add-member"
  258. disabled={isDropdownDisabled}
  259. >
  260. {t('Add Member')}
  261. </DropdownButton>
  262. )}
  263. </DropdownAutoComplete>
  264. );
  265. }
  266. renderPageTextBlock() {
  267. const {organization, team} = this.props;
  268. const {openMembership} = organization;
  269. const isIdpProvisioned = team.flags['idp:provisioned'];
  270. if (isIdpProvisioned) {
  271. return getButtonHelpText(isIdpProvisioned);
  272. }
  273. return openMembership
  274. ? t(
  275. '"Open Membership" is enabled for the organization. Anyone can add members for this team.'
  276. )
  277. : t(
  278. '"Open Membership" is disabled for the organization. Org Owner/Manager/Admin, or Team Admins can add members for this team.'
  279. );
  280. }
  281. renderMembers(isTeamAdmin: boolean) {
  282. const {config, organization, team} = this.props;
  283. const {access} = organization;
  284. // org:admin is a unique scope that only org owners have
  285. const isOrgOwner = access.includes('org:admin');
  286. const {teamMembers, loading} = this.state;
  287. if (loading) {
  288. return <LoadingIndicator />;
  289. }
  290. if (teamMembers.length) {
  291. return teamMembers.map(member => {
  292. return (
  293. <TeamMembersRow
  294. key={member.id}
  295. hasWriteAccess={isTeamAdmin}
  296. isOrgOwner={isOrgOwner}
  297. organization={organization}
  298. team={team}
  299. member={member}
  300. user={config.user}
  301. removeMember={this.removeTeamMember}
  302. updateMemberRole={this.updateTeamMemberRole}
  303. />
  304. );
  305. });
  306. }
  307. return (
  308. <EmptyMessage icon={<IconUser size="xl" />} size="large">
  309. {t('This team has no members')}
  310. </EmptyMessage>
  311. );
  312. }
  313. render() {
  314. if (this.state.error) {
  315. return <LoadingError onRetry={this.fetchData} />;
  316. }
  317. const {organization, team} = this.props;
  318. const {teamMembersPageLinks} = this.state;
  319. const {openMembership} = organization;
  320. const hasOrgWriteAccess = hasEveryAccess(['org:write'], {organization, team});
  321. const hasTeamAdminAccess = hasEveryAccess(['team:admin'], {organization, team});
  322. const isTeamAdmin = hasOrgWriteAccess || hasTeamAdminAccess;
  323. return (
  324. <Fragment>
  325. <TextBlock>{this.renderPageTextBlock()}</TextBlock>
  326. <PermissionAlert
  327. access={openMembership ? ['org:read'] : ['team:write']}
  328. team={team}
  329. />
  330. <Panel>
  331. <StyledPanelHeader hasButtons>
  332. <div>{t('Members')}</div>
  333. <div>
  334. <TeamRoleColumnLabel />
  335. </div>
  336. <div style={{textTransform: 'none'}}>{this.renderDropdown(isTeamAdmin)}</div>
  337. </StyledPanelHeader>
  338. {this.renderMembers(isTeamAdmin)}
  339. </Panel>
  340. <Pagination pageLinks={teamMembersPageLinks} />
  341. </Fragment>
  342. );
  343. }
  344. }
  345. const StyledUserListElement = styled('div')`
  346. display: grid;
  347. grid-template-columns: max-content 1fr;
  348. gap: ${space(0.5)};
  349. align-items: center;
  350. `;
  351. const StyledNameOrEmail = styled('div')`
  352. font-size: ${p => p.theme.fontSizeSmall};
  353. ${p => p.theme.overflowEllipsis};
  354. `;
  355. const StyledAvatar = styled(props => <UserAvatar {...props} />)`
  356. min-width: 1.75em;
  357. min-height: 1.75em;
  358. width: 1.5em;
  359. height: 1.5em;
  360. `;
  361. const StyledMembersLabel = styled('div')`
  362. display: grid;
  363. grid-template-columns: 1fr max-content;
  364. padding: ${space(1)} 0;
  365. font-size: ${p => p.theme.fontSizeExtraSmall};
  366. text-transform: uppercase;
  367. `;
  368. const StyledCreateMemberLink = styled(Link)`
  369. text-transform: none;
  370. `;
  371. const StyledPanelHeader = styled(PanelHeader)`
  372. ${GRID_TEMPLATE}
  373. `;
  374. export default withConfig(withApi(withOrganization(TeamMembers)));