index.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423
  1. import {Fragment} from 'react';
  2. import styled from '@emotion/styled';
  3. import {addErrorMessage} from 'sentry/actionCreators/indicator';
  4. import {Alert} from 'sentry/components/alert';
  5. import {Button} from 'sentry/components/button';
  6. import DeprecatedAsyncComponent from 'sentry/components/deprecatedAsyncComponent';
  7. import SelectControl from 'sentry/components/forms/controls/selectControl';
  8. import FieldGroup from 'sentry/components/forms/fieldGroup';
  9. import IdBadge from 'sentry/components/idBadge';
  10. import ExternalLink from 'sentry/components/links/externalLink';
  11. import LoadingIndicator from 'sentry/components/loadingIndicator';
  12. import NarrowLayout from 'sentry/components/narrowLayout';
  13. import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle';
  14. import {t, tct} from 'sentry/locale';
  15. import ConfigStore from 'sentry/stores/configStore';
  16. import type {Integration, IntegrationProvider} from 'sentry/types/integrations';
  17. import type {RouteComponentProps} from 'sentry/types/legacyReactRouter';
  18. import type {Organization} from 'sentry/types/organization';
  19. import {generateOrgSlugUrl, urlEncode} from 'sentry/utils';
  20. import type {IntegrationAnalyticsKey} from 'sentry/utils/analytics/integrations';
  21. import {
  22. getIntegrationFeatureGate,
  23. trackIntegrationAnalytics,
  24. } from 'sentry/utils/integrationUtil';
  25. import {singleLineRenderer} from 'sentry/utils/marked';
  26. import normalizeUrl from 'sentry/utils/url/normalizeUrl';
  27. import {DisabledNotice} from 'sentry/views/settings/organizationIntegrations/abstractIntegrationDetailedView';
  28. import AddIntegration from 'sentry/views/settings/organizationIntegrations/addIntegration';
  29. // installationId present for Github flow
  30. type Props = RouteComponentProps<{integrationSlug: string; installationId?: string}, {}>;
  31. type State = DeprecatedAsyncComponent['state'] & {
  32. installationData?: GitHubIntegrationInstallation;
  33. installationDataLoading?: boolean;
  34. organization?: Organization;
  35. provider?: IntegrationProvider;
  36. selectedOrgSlug?: string;
  37. };
  38. interface GitHubIntegrationInstallation {
  39. account: {
  40. login: string;
  41. type: string;
  42. };
  43. sender: {
  44. id: number;
  45. login: string;
  46. };
  47. }
  48. export default class IntegrationOrganizationLink extends DeprecatedAsyncComponent<
  49. Props,
  50. State
  51. > {
  52. disableErrorReport = false;
  53. getEndpoints(): ReturnType<DeprecatedAsyncComponent['getEndpoints']> {
  54. return [['organizations', '/organizations/?include_feature_flags=1']];
  55. }
  56. trackIntegrationAnalytics = (
  57. eventName: IntegrationAnalyticsKey,
  58. startSession?: boolean
  59. ) => {
  60. const {organization, provider} = this.state;
  61. // should have these set but need to make TS happy
  62. if (!organization || !provider) {
  63. return;
  64. }
  65. trackIntegrationAnalytics(
  66. eventName,
  67. {
  68. integration_type: 'first_party',
  69. integration: provider.key,
  70. // We actually don't know if it's installed but neither does the user in the view and multiple installs is possible
  71. already_installed: false,
  72. view: 'external_install',
  73. organization,
  74. },
  75. {startSession: !!startSession}
  76. );
  77. };
  78. trackOpened() {
  79. this.trackIntegrationAnalytics('integrations.integration_viewed', true);
  80. }
  81. trackInstallationStart() {
  82. this.trackIntegrationAnalytics('integrations.installation_start');
  83. }
  84. get integrationSlug() {
  85. return this.props.params.integrationSlug;
  86. }
  87. get queryParams() {
  88. return this.props.location.query;
  89. }
  90. getOrgBySlug = (orgSlug: string): Organization | undefined => {
  91. return this.state.organizations.find((org: Organization) => org.slug === orgSlug);
  92. };
  93. onLoadAllEndpointsSuccess() {
  94. // auto select the org if there is only one
  95. const {organizations} = this.state;
  96. if (organizations.length === 1) {
  97. this.onSelectOrg(organizations[0].slug);
  98. }
  99. // now check the subomdain and use that org slug if it exists
  100. const customerDomain = ConfigStore.get('customerDomain');
  101. if (customerDomain?.subdomain) {
  102. this.onSelectOrg(customerDomain.subdomain);
  103. }
  104. }
  105. onSelectOrg = async (orgSlug: string) => {
  106. const customerDomain = ConfigStore.get('customerDomain');
  107. // redirect to the org if it's different than the org being selected
  108. if (customerDomain?.subdomain && orgSlug !== customerDomain?.subdomain) {
  109. const urlWithQuery = generateOrgSlugUrl(orgSlug) + this.props.location.search;
  110. window.location.assign(urlWithQuery);
  111. return;
  112. }
  113. // otherwise proceed as normal
  114. this.setState({selectedOrgSlug: orgSlug, reloading: true, organization: undefined});
  115. try {
  116. const [organization, {providers}]: [
  117. Organization,
  118. {providers: IntegrationProvider[]},
  119. ] = await Promise.all([
  120. this.api.requestPromise(`/organizations/${orgSlug}/`, {
  121. query: {
  122. include_feature_flags: 1,
  123. },
  124. }),
  125. this.api.requestPromise(
  126. `/organizations/${orgSlug}/config/integrations/?provider_key=${this.integrationSlug}`
  127. ),
  128. ]);
  129. // should never happen with a valid provider
  130. if (providers.length === 0) {
  131. throw new Error('Invalid provider');
  132. }
  133. let installationData = undefined;
  134. if (this.integrationSlug === 'github') {
  135. const {installationId} = this.props.params;
  136. try {
  137. // The API endpoint /extensions/github/installation is not prefixed with /api/0
  138. // so we have to use this workaround.
  139. installationData = await this.api.requestPromise(
  140. `/../../extensions/github/installation/${installationId}/`
  141. );
  142. } catch (_err) {
  143. addErrorMessage(t('Failed to retrieve GitHub installation details'));
  144. }
  145. this.setState({installationDataLoading: false});
  146. }
  147. this.setState(
  148. {organization, reloading: false, provider: providers[0], installationData},
  149. this.trackOpened
  150. );
  151. } catch (_err) {
  152. addErrorMessage(t('Failed to retrieve organization or integration details'));
  153. this.setState({reloading: false});
  154. }
  155. };
  156. hasAccess = () => {
  157. const {organization} = this.state;
  158. return organization?.access.includes('org:integrations');
  159. };
  160. // used with Github to redirect to the integration detail
  161. onInstallWithInstallationId = (data: Integration) => {
  162. const {organization} = this.state;
  163. const orgId = organization?.slug;
  164. const normalizedUrl = normalizeUrl(
  165. `/settings/${orgId}/integrations/${data.provider.key}/${data.id}/`
  166. );
  167. window.location.assign(
  168. `${organization?.links.organizationUrl || ''}${normalizedUrl}`
  169. );
  170. };
  171. // non-Github redirects to the extension view where the backend will finish the installation
  172. finishInstallation = () => {
  173. // add the selected org to the query parameters and then redirect back to configure
  174. const {selectedOrgSlug, organization} = this.state;
  175. const query = {orgSlug: selectedOrgSlug, ...this.queryParams};
  176. this.trackInstallationStart();
  177. // need to send to control silo to finish the installation
  178. window.location.assign(
  179. `${organization?.links.organizationUrl || ''}/extensions/${
  180. this.integrationSlug
  181. }/configure/?${urlEncode(query)}`
  182. );
  183. };
  184. renderAddButton() {
  185. const {installationId} = this.props.params;
  186. const {organization, provider} = this.state;
  187. // should never happen but we need this check for TS
  188. if (!provider || !organization) {
  189. return null;
  190. }
  191. const {features} = provider.metadata;
  192. // Prepare the features list
  193. const featuresComponents = features.map(f => ({
  194. featureGate: f.featureGate,
  195. description: (
  196. <FeatureListItem
  197. dangerouslySetInnerHTML={{__html: singleLineRenderer(f.description)}}
  198. />
  199. ),
  200. }));
  201. const {IntegrationFeatures} = getIntegrationFeatureGate();
  202. // Github uses a different installation flow with the installationId as a parameter
  203. // We have to wrap our installation button with AddIntegration so we can get the
  204. // addIntegrationWithInstallationId callback.
  205. // if we don't have an installationId, we need to use the finishInstallation callback.
  206. return (
  207. <IntegrationFeatures organization={organization} features={featuresComponents}>
  208. {({disabled, disabledReason}) => (
  209. <AddIntegration
  210. provider={provider}
  211. onInstall={this.onInstallWithInstallationId}
  212. organization={organization}
  213. >
  214. {addIntegrationWithInstallationId => (
  215. <ButtonWrapper>
  216. <Button
  217. priority="primary"
  218. disabled={!this.hasAccess() || disabled}
  219. onClick={() =>
  220. installationId
  221. ? addIntegrationWithInstallationId({
  222. installation_id: installationId,
  223. })
  224. : this.finishInstallation()
  225. }
  226. >
  227. {t('Install %s', provider.name)}
  228. </Button>
  229. {disabled && <DisabledNotice reason={disabledReason} />}
  230. </ButtonWrapper>
  231. )}
  232. </AddIntegration>
  233. )}
  234. </IntegrationFeatures>
  235. );
  236. }
  237. renderBottom() {
  238. const {organization, selectedOrgSlug, provider, reloading} = this.state;
  239. const {FeatureList} = getIntegrationFeatureGate();
  240. if (reloading) {
  241. return <LoadingIndicator />;
  242. }
  243. return (
  244. <Fragment>
  245. {selectedOrgSlug && organization && !this.hasAccess() && (
  246. <Alert type="error" showIcon>
  247. <p>
  248. {tct(
  249. `You do not have permission to install integrations in
  250. [organization]. Ask an organization owner or manager to
  251. visit this page to finish installing this integration.`,
  252. {organization: <strong>{organization.slug}</strong>}
  253. )}
  254. </p>
  255. <InstallLink>{generateOrgSlugUrl(selectedOrgSlug)}</InstallLink>
  256. </Alert>
  257. )}
  258. {provider && organization && this.hasAccess() && FeatureList && (
  259. <Fragment>
  260. <p>
  261. {tct(
  262. 'The following features will be available for [organization] when installed.',
  263. {organization: <strong>{organization.slug}</strong>}
  264. )}
  265. </p>
  266. <FeatureList
  267. organization={organization}
  268. features={provider.metadata.features}
  269. provider={provider}
  270. />
  271. </Fragment>
  272. )}
  273. <div className="form-actions">{this.renderAddButton()}</div>
  274. </Fragment>
  275. );
  276. }
  277. renderCallout() {
  278. const {installationData, installationDataLoading} = this.state;
  279. if (this.integrationSlug !== 'github') {
  280. return null;
  281. }
  282. if (!installationData) {
  283. if (installationDataLoading !== false) {
  284. return null;
  285. }
  286. return (
  287. <Alert type="warning" showIcon>
  288. {t(
  289. 'We could not verify the authenticity of the installation request. We recommend restarting the installation process.'
  290. )}
  291. </Alert>
  292. );
  293. }
  294. const sender_url = `https://github.com/${installationData?.sender.login}`;
  295. const target_url = `https://github.com/${installationData?.account.login}`;
  296. const alertText = tct(
  297. `GitHub user [sender_login] has installed GitHub app to [account_type] [account_login]. Proceed if you want to attach this installation to your Sentry account.`,
  298. {
  299. account_type: <strong>{installationData?.account.type}</strong>,
  300. account_login: (
  301. <strong>
  302. <ExternalLink href={target_url}>
  303. {installationData?.account.login}
  304. </ExternalLink>
  305. </strong>
  306. ),
  307. sender_id: <strong>{installationData?.sender.id}</strong>,
  308. sender_login: (
  309. <strong>
  310. <ExternalLink href={sender_url}>
  311. {installationData?.sender.login}
  312. </ExternalLink>
  313. </strong>
  314. ),
  315. }
  316. );
  317. return (
  318. <Alert type="info" showIcon>
  319. {alertText}
  320. </Alert>
  321. );
  322. }
  323. renderBody() {
  324. const {selectedOrgSlug} = this.state;
  325. const options = this.state.organizations.map((org: Organization) => ({
  326. value: org.slug,
  327. label: (
  328. <IdBadge
  329. organization={org}
  330. avatarSize={20}
  331. displayName={org.name}
  332. avatarProps={{consistentWidth: true}}
  333. />
  334. ),
  335. }));
  336. return (
  337. <NarrowLayout>
  338. <SentryDocumentTitle title={t('Choose Installation Organization')} />
  339. <h3>{t('Finish integration installation')}</h3>
  340. {this.renderCallout()}
  341. <p>
  342. {tct(
  343. `Please pick a specific [organization:organization] to link with
  344. your integration installation of [integation].`,
  345. {
  346. organization: <strong />,
  347. integation: <strong>{this.integrationSlug}</strong>,
  348. }
  349. )}
  350. </p>
  351. <FieldGroup label={t('Organization')} inline={false} stacked required>
  352. <SelectControl
  353. // @ts-expect-error TS(7031): Binding element 'orgSlug' implicitly has an 'any' ... Remove this comment to see the full error message
  354. onChange={({value: orgSlug}) => this.onSelectOrg(orgSlug)}
  355. value={selectedOrgSlug}
  356. placeholder={t('Select an organization')}
  357. options={options}
  358. />
  359. </FieldGroup>
  360. {this.renderBottom()}
  361. </NarrowLayout>
  362. );
  363. }
  364. }
  365. const InstallLink = styled('pre')`
  366. margin-bottom: 0;
  367. background: #fbe3e1;
  368. `;
  369. const FeatureListItem = styled('span')`
  370. line-height: 24px;
  371. `;
  372. const ButtonWrapper = styled('div')`
  373. margin-left: auto;
  374. align-self: center;
  375. display: flex;
  376. flex-direction: column;
  377. align-items: center;
  378. `;