teamMembers.tsx 12 KB

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