teamMembers.tsx 12 KB

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