index.tsx 13 KB

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