teamMembers.tsx 12 KB

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