index.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509
  1. import {Fragment} from 'react';
  2. import {css} from '@emotion/react';
  3. import styled from '@emotion/styled';
  4. import {ModalRenderProps} from 'sentry/actionCreators/modal';
  5. import Alert from 'sentry/components/alert';
  6. import AsyncComponent from 'sentry/components/asyncComponent';
  7. import {Button} from 'sentry/components/button';
  8. import ButtonBar from 'sentry/components/buttonBar';
  9. import HookOrDefault from 'sentry/components/hookOrDefault';
  10. import LoadingIndicator from 'sentry/components/loadingIndicator';
  11. import {ORG_ROLES} from 'sentry/constants';
  12. import {IconAdd, IconCheckmark, IconWarning} from 'sentry/icons';
  13. import {t, tct, tn} from 'sentry/locale';
  14. import {space} from 'sentry/styles/space';
  15. import {Organization} from 'sentry/types';
  16. import {trackAnalytics} from 'sentry/utils/analytics';
  17. import {uniqueId} from 'sentry/utils/guid';
  18. import withLatestContext from 'sentry/utils/withLatestContext';
  19. import InviteRowControl from './inviteRowControl';
  20. import {InviteRow, InviteStatus, NormalizedInvite} from './types';
  21. type Props = AsyncComponent['props'] &
  22. ModalRenderProps & {
  23. organization: Organization;
  24. initialData?: Partial<InviteRow>[];
  25. source?: string;
  26. };
  27. type State = AsyncComponent['state'] & {
  28. complete: boolean;
  29. inviteStatus: InviteStatus;
  30. pendingInvites: InviteRow[];
  31. sendingInvites: boolean;
  32. };
  33. const DEFAULT_ROLE = 'member';
  34. const InviteModalHook = HookOrDefault({
  35. hookName: 'member-invite-modal:customization',
  36. defaultComponent: ({onSendInvites, children}) =>
  37. children({sendInvites: onSendInvites, canSend: true}),
  38. });
  39. type InviteModalRenderFunc = React.ComponentProps<typeof InviteModalHook>['children'];
  40. class InviteMembersModal extends AsyncComponent<Props, State> {
  41. get inviteTemplate(): InviteRow {
  42. return {
  43. emails: new Set(),
  44. teams: new Set(),
  45. role: DEFAULT_ROLE,
  46. };
  47. }
  48. /**
  49. * Used for analytics tracking of the modals usage.
  50. */
  51. sessionId = '';
  52. componentDidMount() {
  53. this.sessionId = uniqueId();
  54. const {organization, source} = this.props;
  55. trackAnalytics('invite_modal.opened', {
  56. organization,
  57. modal_session: this.sessionId,
  58. can_invite: this.willInvite,
  59. source,
  60. });
  61. }
  62. getEndpoints(): ReturnType<AsyncComponent['getEndpoints']> {
  63. const orgId = this.props.organization.slug;
  64. return [['member', `/organizations/${orgId}/members/me/`]];
  65. }
  66. getDefaultState() {
  67. const state = super.getDefaultState();
  68. const {initialData} = this.props;
  69. const pendingInvites = initialData
  70. ? initialData.map(initial => ({
  71. ...this.inviteTemplate,
  72. ...initial,
  73. }))
  74. : [this.inviteTemplate];
  75. return {
  76. ...state,
  77. pendingInvites,
  78. inviteStatus: {},
  79. complete: false,
  80. sendingInvites: false,
  81. };
  82. }
  83. reset = () => {
  84. this.setState({
  85. pendingInvites: [this.inviteTemplate],
  86. inviteStatus: {},
  87. complete: false,
  88. sendingInvites: false,
  89. });
  90. trackAnalytics('invite_modal.add_more', {
  91. organization: this.props.organization,
  92. modal_session: this.sessionId,
  93. });
  94. };
  95. sendInvite = async (invite: NormalizedInvite) => {
  96. const {slug} = this.props.organization;
  97. const data = {
  98. email: invite.email,
  99. teams: [...invite.teams],
  100. role: invite.role,
  101. };
  102. this.setState(state => ({
  103. inviteStatus: {...state.inviteStatus, [invite.email]: {sent: false}},
  104. }));
  105. const endpoint = this.willInvite
  106. ? `/organizations/${slug}/members/`
  107. : `/organizations/${slug}/invite-requests/`;
  108. try {
  109. await this.api.requestPromise(endpoint, {method: 'POST', data});
  110. } catch (err) {
  111. const errorResponse = err.responseJSON;
  112. // Use the email error message if available. This inconsistently is
  113. // returned as either a list of errors for the field, or a single error.
  114. const emailError =
  115. !errorResponse || !errorResponse.email
  116. ? false
  117. : Array.isArray(errorResponse.email)
  118. ? errorResponse.email[0]
  119. : errorResponse.email;
  120. const error = emailError || t('Could not invite user');
  121. this.setState(state => ({
  122. inviteStatus: {...state.inviteStatus, [invite.email]: {sent: false, error}},
  123. }));
  124. return;
  125. }
  126. this.setState(state => ({
  127. inviteStatus: {...state.inviteStatus, [invite.email]: {sent: true}},
  128. }));
  129. };
  130. sendInvites = async () => {
  131. this.setState({sendingInvites: true});
  132. await Promise.all(this.invites.map(this.sendInvite));
  133. this.setState({sendingInvites: false, complete: true});
  134. trackAnalytics(
  135. this.willInvite ? 'invite_modal.invites_sent' : 'invite_modal.requests_sent',
  136. {
  137. organization: this.props.organization,
  138. modal_session: this.sessionId,
  139. }
  140. );
  141. };
  142. addInviteRow = () =>
  143. this.setState(state => ({
  144. pendingInvites: [...state.pendingInvites, this.inviteTemplate],
  145. }));
  146. setEmails(emails: string[], index: number) {
  147. this.setState(state => {
  148. const pendingInvites = [...state.pendingInvites];
  149. pendingInvites[index] = {...pendingInvites[index], emails: new Set(emails)};
  150. return {pendingInvites};
  151. });
  152. }
  153. setTeams(teams: string[], index: number) {
  154. this.setState(state => {
  155. const pendingInvites = [...state.pendingInvites];
  156. pendingInvites[index] = {...pendingInvites[index], teams: new Set(teams)};
  157. return {pendingInvites};
  158. });
  159. }
  160. setRole(role: string, index: number) {
  161. this.setState(state => {
  162. const pendingInvites = [...state.pendingInvites];
  163. pendingInvites[index] = {...pendingInvites[index], role};
  164. return {pendingInvites};
  165. });
  166. }
  167. removeInviteRow(index: number) {
  168. this.setState(state => {
  169. const pendingInvites = [...state.pendingInvites];
  170. pendingInvites.splice(index, 1);
  171. return {pendingInvites};
  172. });
  173. }
  174. get invites(): NormalizedInvite[] {
  175. return this.state.pendingInvites.reduce<NormalizedInvite[]>(
  176. (acc, row) => [
  177. ...acc,
  178. ...[...row.emails].map(email => ({email, teams: row.teams, role: row.role})),
  179. ],
  180. []
  181. );
  182. }
  183. get hasDuplicateEmails() {
  184. const emails = this.invites.map(inv => inv.email);
  185. return emails.length !== new Set(emails).size;
  186. }
  187. get isValidInvites() {
  188. return this.invites.length > 0 && !this.hasDuplicateEmails;
  189. }
  190. get statusMessage() {
  191. const {sendingInvites, complete, inviteStatus} = this.state;
  192. if (sendingInvites) {
  193. return (
  194. <StatusMessage>
  195. <LoadingIndicator mini relative hideMessage size={16} />
  196. {this.willInvite
  197. ? t('Sending organization invitations\u2026')
  198. : t('Sending invite requests\u2026')}
  199. </StatusMessage>
  200. );
  201. }
  202. if (complete) {
  203. const statuses = Object.values(inviteStatus);
  204. const sentCount = statuses.filter(i => i.sent).length;
  205. const errorCount = statuses.filter(i => i.error).length;
  206. if (this.willInvite) {
  207. const invites = <strong>{tn('%s invite', '%s invites', sentCount)}</strong>;
  208. const tctComponents = {
  209. invites,
  210. failed: errorCount,
  211. };
  212. return (
  213. <StatusMessage status="success">
  214. <IconCheckmark size="sm" />
  215. {errorCount > 0
  216. ? tct('Sent [invites], [failed] failed to send.', tctComponents)
  217. : tct('Sent [invites]', tctComponents)}
  218. </StatusMessage>
  219. );
  220. }
  221. const inviteRequests = (
  222. <strong>{tn('%s invite request', '%s invite requests', sentCount)}</strong>
  223. );
  224. const tctComponents = {
  225. inviteRequests,
  226. failed: errorCount,
  227. };
  228. return (
  229. <StatusMessage status="success">
  230. <IconCheckmark size="sm" />
  231. {errorCount > 0
  232. ? tct(
  233. '[inviteRequests] pending approval, [failed] failed to send.',
  234. tctComponents
  235. )
  236. : tct('[inviteRequests] pending approval', tctComponents)}
  237. </StatusMessage>
  238. );
  239. }
  240. if (this.hasDuplicateEmails) {
  241. return (
  242. <StatusMessage status="error">
  243. <IconWarning size="sm" />
  244. {t('Duplicate emails between invite rows.')}
  245. </StatusMessage>
  246. );
  247. }
  248. return null;
  249. }
  250. get willInvite() {
  251. return this.props.organization.access?.includes('member:write');
  252. }
  253. get inviteButtonLabel() {
  254. if (this.invites.length > 0) {
  255. const numberInvites = this.invites.length;
  256. // Note we use `t()` here because `tn()` expects the same # of string formatters
  257. const inviteText =
  258. numberInvites === 1 ? t('Send invite') : t('Send invites (%s)', numberInvites);
  259. const requestText =
  260. numberInvites === 1
  261. ? t('Send invite request')
  262. : t('Send invite requests (%s)', numberInvites);
  263. return this.willInvite ? inviteText : requestText;
  264. }
  265. return this.willInvite ? t('Send invite') : t('Send invite request');
  266. }
  267. render() {
  268. const {Footer, closeModal, organization} = this.props;
  269. const {pendingInvites, sendingInvites, complete, inviteStatus, member} = this.state;
  270. const disableInputs = sendingInvites || complete;
  271. // eslint-disable-next-line react/prop-types
  272. const hookRenderer: InviteModalRenderFunc = ({sendInvites, canSend, headerInfo}) => (
  273. <Fragment>
  274. <Heading>{t('Invite New Members')}</Heading>
  275. {this.willInvite ? (
  276. <Subtext>{t('Invite new members by email to join your organization.')}</Subtext>
  277. ) : (
  278. <Alert type="warning" showIcon>
  279. {t(
  280. 'You can’t invite users directly, but we’ll forward your request to an org owner or manager for approval.'
  281. )}
  282. </Alert>
  283. )}
  284. {headerInfo}
  285. <InviteeHeadings>
  286. <div>{t('Email addresses')}</div>
  287. <div>{t('Role')}</div>
  288. <div>{t('Add to team')}</div>
  289. </InviteeHeadings>
  290. <Rows>
  291. {pendingInvites.map(({emails, role, teams}, i) => (
  292. <StyledInviteRow
  293. key={i}
  294. disabled={disableInputs}
  295. emails={[...emails]}
  296. role={role}
  297. teams={[...teams]}
  298. roleOptions={member ? member.roles : ORG_ROLES}
  299. roleDisabledUnallowed={this.willInvite}
  300. inviteStatus={inviteStatus}
  301. onRemove={() => this.removeInviteRow(i)}
  302. onChangeEmails={opts => this.setEmails(opts?.map(v => v.value) ?? [], i)}
  303. onChangeRole={value => this.setRole(value?.value, i)}
  304. onChangeTeams={opts => this.setTeams(opts ? opts.map(v => v.value) : [], i)}
  305. disableRemove={disableInputs || pendingInvites.length === 1}
  306. />
  307. ))}
  308. </Rows>
  309. <AddButton
  310. disabled={disableInputs}
  311. size="sm"
  312. borderless
  313. onClick={this.addInviteRow}
  314. icon={<IconAdd size="xs" isCircled />}
  315. >
  316. {t('Add another')}
  317. </AddButton>
  318. <Footer>
  319. <FooterContent>
  320. <div>{this.statusMessage}</div>
  321. <ButtonBar gap={1}>
  322. {complete ? (
  323. <Fragment>
  324. <Button data-test-id="send-more" size="sm" onClick={this.reset}>
  325. {t('Send more invites')}
  326. </Button>
  327. <Button
  328. data-test-id="close"
  329. priority="primary"
  330. size="sm"
  331. onClick={() => {
  332. trackAnalytics('invite_modal.closed', {
  333. organization: this.props.organization,
  334. modal_session: this.sessionId,
  335. });
  336. closeModal();
  337. }}
  338. >
  339. {t('Close')}
  340. </Button>
  341. </Fragment>
  342. ) : (
  343. <Fragment>
  344. <Button
  345. data-test-id="cancel"
  346. size="sm"
  347. onClick={closeModal}
  348. disabled={disableInputs}
  349. >
  350. {t('Cancel')}
  351. </Button>
  352. <Button
  353. size="sm"
  354. data-test-id="send-invites"
  355. priority="primary"
  356. disabled={!canSend || !this.isValidInvites || disableInputs}
  357. onClick={sendInvites}
  358. >
  359. {this.inviteButtonLabel}
  360. </Button>
  361. </Fragment>
  362. )}
  363. </ButtonBar>
  364. </FooterContent>
  365. </Footer>
  366. </Fragment>
  367. );
  368. return (
  369. <InviteModalHook
  370. organization={organization}
  371. willInvite={this.willInvite}
  372. onSendInvites={this.sendInvites}
  373. >
  374. {hookRenderer}
  375. </InviteModalHook>
  376. );
  377. }
  378. }
  379. const Heading = styled('h1')`
  380. font-weight: 400;
  381. font-size: ${p => p.theme.headerFontSize};
  382. margin-top: 0;
  383. margin-bottom: ${space(0.75)};
  384. `;
  385. const Subtext = styled('p')`
  386. color: ${p => p.theme.subText};
  387. margin-bottom: ${space(3)};
  388. `;
  389. const inviteRowGrid = css`
  390. display: grid;
  391. gap: ${space(1.5)};
  392. grid-template-columns: 3fr 180px 2fr max-content;
  393. align-items: start;
  394. `;
  395. const InviteeHeadings = styled('div')`
  396. ${inviteRowGrid};
  397. margin-bottom: ${space(1)};
  398. font-weight: 600;
  399. text-transform: uppercase;
  400. font-size: ${p => p.theme.fontSizeSmall};
  401. `;
  402. const Rows = styled('ul')`
  403. list-style: none;
  404. padding: 0;
  405. margin: 0;
  406. `;
  407. const StyledInviteRow = styled(InviteRowControl)`
  408. ${inviteRowGrid};
  409. margin-bottom: ${space(1.5)};
  410. `;
  411. const AddButton = styled(Button)`
  412. margin-top: ${space(3)};
  413. `;
  414. const FooterContent = styled('div')`
  415. display: flex;
  416. gap: ${space(1)};
  417. align-items: center;
  418. justify-content: space-between;
  419. flex: 1;
  420. `;
  421. const StatusMessage = styled('div')<{status?: 'success' | 'error'}>`
  422. display: flex;
  423. gap: ${space(1)};
  424. align-items: center;
  425. font-size: ${p => p.theme.fontSizeMedium};
  426. color: ${p => (p.status === 'error' ? p.theme.errorText : p.theme.textColor)};
  427. > :first-child {
  428. ${p => p.status === 'success' && `color: ${p.theme.successText}`};
  429. }
  430. `;
  431. export const modalCss = css`
  432. width: 100%;
  433. max-width: 900px;
  434. margin: 50px auto;
  435. `;
  436. export default withLatestContext(InviteMembersModal);