index.tsx 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336
  1. import {RouteComponentProps} from 'react-router';
  2. import styled from '@emotion/styled';
  3. import {addErrorMessage} from 'sentry/actionCreators/indicator';
  4. import {installSentryApp} from 'sentry/actionCreators/sentryAppInstallations';
  5. import {Client} from 'sentry/api';
  6. import {Alert} from 'sentry/components/alert';
  7. import OrganizationAvatar from 'sentry/components/avatar/organizationAvatar';
  8. import SelectControl from 'sentry/components/forms/controls/selectControl';
  9. import FieldGroup from 'sentry/components/forms/fieldGroup';
  10. import SentryAppDetailsModal from 'sentry/components/modals/sentryAppDetailsModal';
  11. import NarrowLayout from 'sentry/components/narrowLayout';
  12. import {t, tct} from 'sentry/locale';
  13. import {Organization, SentryApp, SentryAppInstallation} from 'sentry/types';
  14. import {generateBaseControlSiloUrl, generateOrgSlugUrl} from 'sentry/utils';
  15. import {trackIntegrationAnalytics} from 'sentry/utils/integrationUtil';
  16. import {addQueryParamsToExistingUrl} from 'sentry/utils/queryString';
  17. import DeprecatedAsyncView from 'sentry/views/deprecatedAsyncView';
  18. import {OrganizationContext} from '../organizationContext';
  19. type Props = RouteComponentProps<{sentryAppSlug: string}, {}>;
  20. type State = DeprecatedAsyncView['state'] & {
  21. organization: Organization | null;
  22. organizations: Organization[];
  23. reloading: boolean;
  24. selectedOrgSlug: string | null;
  25. sentryApp: SentryApp;
  26. };
  27. export default class SentryAppExternalInstallation extends DeprecatedAsyncView<
  28. Props,
  29. State
  30. > {
  31. disableErrorReport = false;
  32. controlSiloApi = new Client({baseUrl: generateBaseControlSiloUrl() + '/api/0'});
  33. getDefaultState() {
  34. const state = super.getDefaultState();
  35. return {
  36. ...state,
  37. selectedOrgSlug: null,
  38. organization: null,
  39. organizations: [],
  40. reloading: false,
  41. };
  42. }
  43. getEndpoints(): ReturnType<DeprecatedAsyncView['getEndpoints']> {
  44. return [
  45. ['organizations', '/organizations/'],
  46. ['sentryApp', `/sentry-apps/${this.sentryAppSlug}/`],
  47. ];
  48. }
  49. getTitle() {
  50. return t('Choose Installation Organization');
  51. }
  52. get sentryAppSlug() {
  53. return this.props.params.sentryAppSlug;
  54. }
  55. get isSingleOrg() {
  56. return this.state.organizations.length === 1;
  57. }
  58. get isSentryAppInternal() {
  59. const {sentryApp} = this.state;
  60. return sentryApp && sentryApp.status === 'internal';
  61. }
  62. get isSentryAppUnavailableForOrg() {
  63. const {sentryApp, selectedOrgSlug} = this.state;
  64. // if the app is unpublished for a different org
  65. return (
  66. selectedOrgSlug &&
  67. sentryApp?.owner?.slug !== selectedOrgSlug &&
  68. sentryApp.status === 'unpublished'
  69. );
  70. }
  71. get disableInstall() {
  72. const {reloading, isInstalled} = this.state;
  73. return isInstalled || reloading || this.isSentryAppUnavailableForOrg;
  74. }
  75. hasAccess = (org: Organization) => org.access.includes('org:integrations');
  76. onClose = () => {
  77. // if we came from somewhere, go back there. Otherwise, back to the integrations page
  78. const {selectedOrgSlug} = this.state;
  79. const newUrl = document.referrer || `/settings/${selectedOrgSlug}/integrations/`;
  80. window.location.assign(newUrl);
  81. };
  82. onInstall = async (): Promise<any | undefined> => {
  83. const {organization, sentryApp} = this.state;
  84. if (!organization || !sentryApp) {
  85. return undefined;
  86. }
  87. trackIntegrationAnalytics('integrations.installation_start', {
  88. integration_type: 'sentry_app',
  89. integration: sentryApp.slug,
  90. view: 'external_install',
  91. integration_status: sentryApp.status,
  92. organization,
  93. });
  94. const install = await installSentryApp(
  95. this.controlSiloApi,
  96. organization.slug,
  97. sentryApp
  98. );
  99. // installation is complete if the status is installed
  100. if (install.status === 'installed') {
  101. trackIntegrationAnalytics('integrations.installation_complete', {
  102. integration_type: 'sentry_app',
  103. integration: sentryApp.slug,
  104. view: 'external_install',
  105. integration_status: sentryApp.status,
  106. organization,
  107. });
  108. }
  109. if (sentryApp.redirectUrl) {
  110. const queryParams = {
  111. installationId: install.uuid,
  112. code: install.code,
  113. orgSlug: organization.slug,
  114. };
  115. const redirectUrl = addQueryParamsToExistingUrl(sentryApp.redirectUrl, queryParams);
  116. return window.location.assign(redirectUrl);
  117. }
  118. return this.onClose();
  119. };
  120. onSelectOrg = async (orgSlug: string) => {
  121. this.setState({selectedOrgSlug: orgSlug, reloading: true});
  122. try {
  123. const [organization, installations]: [Organization, SentryAppInstallation[]] =
  124. await Promise.all([
  125. this.controlSiloApi.requestPromise(`/organizations/${orgSlug}/`),
  126. this.controlSiloApi.requestPromise(
  127. `/organizations/${orgSlug}/sentry-app-installations/`
  128. ),
  129. ]);
  130. const isInstalled = installations
  131. .map(install => install.app.slug)
  132. .includes(this.sentryAppSlug);
  133. // all state fields should be set at the same time so analytics in SentryAppDetailsModal works properly
  134. this.setState({organization, isInstalled, reloading: false});
  135. } catch (err) {
  136. addErrorMessage(t('Failed to retrieve organization or integration details'));
  137. this.setState({reloading: false});
  138. }
  139. };
  140. onRequestSuccess = ({stateKey, data}) => {
  141. // if only one org, we can immediately update our selected org
  142. if (stateKey === 'organizations' && data.length === 1) {
  143. this.onSelectOrg(data[0].slug);
  144. }
  145. };
  146. getOptions() {
  147. return this.state.organizations.map(org => ({
  148. value: org.slug,
  149. label: (
  150. <div key={org.slug}>
  151. <OrganizationAvatar organization={org} />
  152. <OrgNameHolder>{org.slug}</OrgNameHolder>
  153. </div>
  154. ),
  155. }));
  156. }
  157. renderInternalAppError() {
  158. const {sentryApp} = this.state;
  159. return (
  160. <Alert type="error" showIcon>
  161. {tct(
  162. 'Integration [sentryAppName] is an internal integration. Internal integrations are automatically installed',
  163. {
  164. sentryAppName: <strong>{sentryApp.name}</strong>,
  165. }
  166. )}
  167. </Alert>
  168. );
  169. }
  170. checkAndRenderError() {
  171. const {organization, selectedOrgSlug, isInstalled, sentryApp} = this.state;
  172. if (selectedOrgSlug && organization && !this.hasAccess(organization)) {
  173. return (
  174. <Alert type="error" showIcon>
  175. <p>
  176. {tct(
  177. `You do not have permission to install integrations in
  178. [organization]. Ask an organization owner or manager to
  179. visit this page to finish installing this integration.`,
  180. {organization: <strong>{organization.slug}</strong>}
  181. )}
  182. </p>
  183. <InstallLink>{generateOrgSlugUrl(selectedOrgSlug)}</InstallLink>
  184. </Alert>
  185. );
  186. }
  187. if (isInstalled && organization) {
  188. return (
  189. <Alert type="error" showIcon>
  190. {tct('Integration [sentryAppName] already installed for [organization]', {
  191. organization: <strong>{organization.name}</strong>,
  192. sentryAppName: <strong>{sentryApp.name}</strong>,
  193. })}
  194. </Alert>
  195. );
  196. }
  197. if (this.isSentryAppUnavailableForOrg) {
  198. // use the slug of the owner if we have it, otherwise use 'another organization'
  199. const ownerSlug = sentryApp?.owner?.slug ?? 'another organization';
  200. return (
  201. <Alert type="error" showIcon>
  202. {tct(
  203. 'Integration [sentryAppName] is an unpublished integration for [otherOrg]. An unpublished integration can only be installed on the organization which created it.',
  204. {
  205. sentryAppName: <strong>{sentryApp.name}</strong>,
  206. otherOrg: <strong>{ownerSlug}</strong>,
  207. }
  208. )}
  209. </Alert>
  210. );
  211. }
  212. return null;
  213. }
  214. renderMultiOrgView() {
  215. const {selectedOrgSlug, sentryApp} = this.state;
  216. return (
  217. <div>
  218. <p>
  219. {tct(
  220. 'Please pick a specific [organization:organization] to install [sentryAppName]',
  221. {
  222. organization: <strong />,
  223. sentryAppName: <strong>{sentryApp.name}</strong>,
  224. }
  225. )}
  226. </p>
  227. <FieldGroup label={t('Organization')} inline={false} stacked required>
  228. {() => (
  229. <SelectControl
  230. onChange={({value}) => this.onSelectOrg(value)}
  231. value={selectedOrgSlug}
  232. placeholder={t('Select an organization')}
  233. options={this.getOptions()}
  234. />
  235. )}
  236. </FieldGroup>
  237. </div>
  238. );
  239. }
  240. renderSingleOrgView() {
  241. const {organizations, sentryApp} = this.state;
  242. // pull the name out of organizations since state.organization won't be loaded initially
  243. const organizationName = organizations[0].name;
  244. return (
  245. <div>
  246. <p>
  247. {tct('You are installing [sentryAppName] for organization [organization]', {
  248. organization: <strong>{organizationName}</strong>,
  249. sentryAppName: <strong>{sentryApp.name}</strong>,
  250. })}
  251. </p>
  252. </div>
  253. );
  254. }
  255. renderMainContent() {
  256. const {organization, sentryApp} = this.state;
  257. return (
  258. <div>
  259. <OrgViewHolder>
  260. {this.isSingleOrg ? this.renderSingleOrgView() : this.renderMultiOrgView()}
  261. </OrgViewHolder>
  262. {this.checkAndRenderError()}
  263. {organization && (
  264. <OrganizationContext.Provider value={organization}>
  265. <SentryAppDetailsModal
  266. sentryApp={sentryApp}
  267. organization={organization}
  268. onInstall={this.onInstall}
  269. closeModal={this.onClose}
  270. isInstalled={this.disableInstall}
  271. />
  272. </OrganizationContext.Provider>
  273. )}
  274. </div>
  275. );
  276. }
  277. renderBody() {
  278. return (
  279. <NarrowLayout>
  280. <Content>
  281. <h3>{t('Finish integration installation')}</h3>
  282. {this.isSentryAppInternal
  283. ? this.renderInternalAppError()
  284. : this.renderMainContent()}
  285. </Content>
  286. </NarrowLayout>
  287. );
  288. }
  289. }
  290. const InstallLink = styled('pre')`
  291. margin-bottom: 0;
  292. background: #fbe3e1;
  293. `;
  294. const OrgNameHolder = styled('span')`
  295. margin-left: 5px;
  296. `;
  297. const Content = styled('div')`
  298. margin-bottom: 40px;
  299. `;
  300. const OrgViewHolder = styled('div')`
  301. margin-bottom: 20px;
  302. `;