index.tsx 13 KB

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