teamMembers.tsx 12 KB

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