index.tsx 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306
  1. import {Fragment} from 'react';
  2. import styled from '@emotion/styled';
  3. import {logout} from 'sentry/actionCreators/account';
  4. import {Alert} from 'sentry/components/alert';
  5. import {Button, LinkButton} from 'sentry/components/button';
  6. import ExternalLink from 'sentry/components/links/externalLink';
  7. import Link from 'sentry/components/links/link';
  8. import NarrowLayout from 'sentry/components/narrowLayout';
  9. import {t, tct} from 'sentry/locale';
  10. import ConfigStore from 'sentry/stores/configStore';
  11. import {space} from 'sentry/styles/space';
  12. import type {RouteComponentProps} from 'sentry/types/legacyReactRouter';
  13. import {browserHistory} from 'sentry/utils/browserHistory';
  14. import DeprecatedAsyncView from 'sentry/views/deprecatedAsyncView';
  15. import SettingsPageHeader from 'sentry/views/settings/components/settingsPageHeader';
  16. type InviteDetails = {
  17. existingMember: boolean;
  18. hasAuthProvider: boolean;
  19. needs2fa: boolean;
  20. needsAuthentication: boolean;
  21. orgSlug: string;
  22. requireSso: boolean;
  23. ssoProvider?: string;
  24. };
  25. type Props = RouteComponentProps<{memberId: string; token: string; orgId?: string}, {}>;
  26. type State = DeprecatedAsyncView['state'] & {
  27. acceptError: boolean | undefined;
  28. accepting: boolean | undefined;
  29. inviteDetails: InviteDetails;
  30. };
  31. class AcceptOrganizationInvite extends DeprecatedAsyncView<Props, State> {
  32. disableErrorReport = false;
  33. get orgSlug(): string | null {
  34. const {params} = this.props;
  35. if (params.orgId) {
  36. return params.orgId;
  37. }
  38. const customerDomain = ConfigStore.get('customerDomain');
  39. if (customerDomain?.subdomain) {
  40. return customerDomain.subdomain;
  41. }
  42. return null;
  43. }
  44. getEndpoints(): ReturnType<DeprecatedAsyncView['getEndpoints']> {
  45. const {memberId, token} = this.props.params;
  46. if (this.orgSlug) {
  47. return [['inviteDetails', `/accept-invite/${this.orgSlug}/${memberId}/${token}/`]];
  48. }
  49. return [['inviteDetails', `/accept-invite/${memberId}/${token}/`]];
  50. }
  51. getTitle() {
  52. return t('Accept Organization Invite');
  53. }
  54. handleLogout = (e: React.MouseEvent) => {
  55. e.preventDefault();
  56. logout(this.api);
  57. };
  58. handleAcceptInvite = async () => {
  59. const {memberId, token} = this.props.params;
  60. this.setState({accepting: true});
  61. try {
  62. if (this.orgSlug) {
  63. await this.api.requestPromise(
  64. `/accept-invite/${this.orgSlug}/${memberId}/${token}/`,
  65. {
  66. method: 'POST',
  67. }
  68. );
  69. } else {
  70. await this.api.requestPromise(`/accept-invite/${memberId}/${token}/`, {
  71. method: 'POST',
  72. });
  73. }
  74. browserHistory.replace(`/${this.state.inviteDetails.orgSlug}/`);
  75. } catch {
  76. this.setState({acceptError: true});
  77. }
  78. this.setState({accepting: false});
  79. };
  80. get existingMemberAlert() {
  81. const user = ConfigStore.get('user');
  82. return (
  83. <Alert type="warning" data-test-id="existing-member">
  84. {tct(
  85. 'Your account ([email]) is already a member of this organization. [switchLink:Switch accounts]?',
  86. {
  87. email: user.email,
  88. switchLink: (
  89. <Link
  90. to=""
  91. data-test-id="existing-member-link"
  92. onClick={this.handleLogout}
  93. />
  94. ),
  95. }
  96. )}
  97. </Alert>
  98. );
  99. }
  100. get authenticationActions() {
  101. const {inviteDetails} = this.state;
  102. return (
  103. <Fragment>
  104. {!inviteDetails.requireSso && (
  105. <p data-test-id="action-info-general">
  106. {t(
  107. `To continue, you must either create a new account, or login to an
  108. existing Sentry account.`
  109. )}
  110. </p>
  111. )}
  112. {inviteDetails.hasAuthProvider && (
  113. <p data-test-id="action-info-sso">
  114. {inviteDetails.requireSso
  115. ? tct(
  116. `Note that [orgSlug] has required Single Sign-On (SSO) using
  117. [authProvider]. You may create an account by authenticating with
  118. the organization's SSO provider.`,
  119. {
  120. orgSlug: <strong>{inviteDetails.orgSlug}</strong>,
  121. authProvider: inviteDetails.ssoProvider,
  122. }
  123. )
  124. : tct(
  125. `Note that [orgSlug] has enabled Single Sign-On (SSO) using
  126. [authProvider]. You may create an account by authenticating with
  127. the organization's SSO provider.`,
  128. {
  129. orgSlug: <strong>{inviteDetails.orgSlug}</strong>,
  130. authProvider: inviteDetails.ssoProvider,
  131. }
  132. )}
  133. </p>
  134. )}
  135. <Actions>
  136. <ActionsLeft>
  137. {inviteDetails.hasAuthProvider && (
  138. <LinkButton
  139. data-test-id="sso-login"
  140. priority="primary"
  141. href={`/auth/login/${inviteDetails.orgSlug}/`}
  142. >
  143. {t('Join with %s', inviteDetails.ssoProvider)}
  144. </LinkButton>
  145. )}
  146. {!inviteDetails.requireSso && (
  147. <LinkButton
  148. data-test-id="create-account"
  149. priority="primary"
  150. href="/auth/register/"
  151. >
  152. {t('Create a new account')}
  153. </LinkButton>
  154. )}
  155. </ActionsLeft>
  156. {!inviteDetails.requireSso && (
  157. <ExternalLink
  158. href="/auth/login/"
  159. openInNewTab={false}
  160. data-test-id="link-with-existing"
  161. >
  162. {t('Login using an existing account')}
  163. </ExternalLink>
  164. )}
  165. </Actions>
  166. </Fragment>
  167. );
  168. }
  169. get warning2fa() {
  170. const {inviteDetails} = this.state;
  171. return (
  172. <Fragment>
  173. <p data-test-id="2fa-warning">
  174. {tct(
  175. 'To continue, [orgSlug] requires all members to configure two-factor authentication.',
  176. {orgSlug: inviteDetails.orgSlug}
  177. )}
  178. </p>
  179. <Actions>
  180. <LinkButton priority="primary" to="/settings/account/security/">
  181. {t('Configure Two-Factor Auth')}
  182. </LinkButton>
  183. </Actions>
  184. </Fragment>
  185. );
  186. }
  187. get acceptActions() {
  188. const {inviteDetails, accepting} = this.state;
  189. return (
  190. <Fragment>
  191. {inviteDetails.hasAuthProvider && !inviteDetails.requireSso && (
  192. <p data-test-id="action-info-sso">
  193. {tct(
  194. `Note that [orgSlug] has enabled Single Sign-On (SSO) using
  195. [authProvider]. You may join the organization by authenticating with
  196. the organization's SSO provider or via your standard account authentication.`,
  197. {
  198. orgSlug: <strong>{inviteDetails.orgSlug}</strong>,
  199. authProvider: inviteDetails.ssoProvider,
  200. }
  201. )}
  202. </p>
  203. )}
  204. <Actions>
  205. <ActionsLeft>
  206. {inviteDetails.hasAuthProvider && !inviteDetails.requireSso && (
  207. <LinkButton
  208. data-test-id="sso-login"
  209. priority="primary"
  210. href={`/auth/login/${inviteDetails.orgSlug}/`}
  211. >
  212. {t('Join with %s', inviteDetails.ssoProvider)}
  213. </LinkButton>
  214. )}
  215. <Button
  216. data-test-id="join-organization"
  217. priority="primary"
  218. disabled={accepting}
  219. onClick={this.handleAcceptInvite}
  220. >
  221. {t('Join the %s organization', inviteDetails.orgSlug)}
  222. </Button>
  223. </ActionsLeft>
  224. </Actions>
  225. </Fragment>
  226. );
  227. }
  228. renderError() {
  229. return (
  230. <NarrowLayout>
  231. <Alert type="warning">
  232. {t('This organization invite link is no longer valid.')}
  233. </Alert>
  234. </NarrowLayout>
  235. );
  236. }
  237. renderBody() {
  238. const {inviteDetails, acceptError} = this.state;
  239. return (
  240. <NarrowLayout>
  241. <SettingsPageHeader title={t('Accept organization invite')} />
  242. {acceptError && (
  243. <Alert type="error">
  244. {t('Failed to join this organization. Please try again')}
  245. </Alert>
  246. )}
  247. <InviteDescription data-test-id="accept-invite">
  248. {tct('[orgSlug] is using Sentry to track and debug errors.', {
  249. orgSlug: <strong>{inviteDetails.orgSlug}</strong>,
  250. })}
  251. </InviteDescription>
  252. {inviteDetails.needsAuthentication
  253. ? this.authenticationActions
  254. : inviteDetails.existingMember
  255. ? this.existingMemberAlert
  256. : inviteDetails.needs2fa
  257. ? this.warning2fa
  258. : inviteDetails.requireSso
  259. ? this.authenticationActions
  260. : this.acceptActions}
  261. </NarrowLayout>
  262. );
  263. }
  264. }
  265. const Actions = styled('div')`
  266. display: flex;
  267. align-items: center;
  268. justify-content: space-between;
  269. margin-bottom: ${space(3)};
  270. `;
  271. const ActionsLeft = styled('span')`
  272. > a {
  273. margin-right: ${space(1)};
  274. }
  275. `;
  276. const InviteDescription = styled('p')`
  277. font-size: 1.2em;
  278. `;
  279. export default AcceptOrganizationInvite;