index.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398
  1. import {useCallback, useEffect, useState} from 'react';
  2. import styled from '@emotion/styled';
  3. import {addErrorMessage} from 'sentry/actionCreators/indicator';
  4. import {fetchOrganizations} from 'sentry/actionCreators/organizations';
  5. import {installSentryApp} from 'sentry/actionCreators/sentryAppInstallations';
  6. import OrganizationAvatar from 'sentry/components/avatar/organizationAvatar';
  7. import {Alert} from 'sentry/components/core/alert';
  8. import {Select} from 'sentry/components/core/select';
  9. import FieldGroup from 'sentry/components/forms/fieldGroup';
  10. import LoadingIndicator from 'sentry/components/loadingIndicator';
  11. import SentryAppDetailsModal from 'sentry/components/modals/sentryAppDetailsModal';
  12. import NarrowLayout from 'sentry/components/narrowLayout';
  13. import {t, tct} from 'sentry/locale';
  14. import ConfigStore from 'sentry/stores/configStore';
  15. import type {SentryApp, SentryAppInstallation} from 'sentry/types/integrations';
  16. import type {RouteComponentProps} from 'sentry/types/legacyReactRouter';
  17. import type {Organization, OrganizationSummary} from 'sentry/types/organization';
  18. import {generateOrgSlugUrl} from 'sentry/utils';
  19. import {trackIntegrationAnalytics} from 'sentry/utils/integrationUtil';
  20. import {useApiQuery} from 'sentry/utils/queryClient';
  21. import {addQueryParamsToExistingUrl} from 'sentry/utils/queryString';
  22. import useApi from 'sentry/utils/useApi';
  23. import {OrganizationContext} from '../organizationContext';
  24. type Props = RouteComponentProps<{sentryAppSlug: string}>;
  25. // Page Layout
  26. export default function SentryAppExternalInstallation(props: Props) {
  27. return (
  28. <NarrowLayout>
  29. <Content>
  30. <h3>{t('Finish integration installation')}</h3>
  31. <SentryAppExternalInstallationContent {...props} />
  32. </Content>
  33. </NarrowLayout>
  34. );
  35. }
  36. // View Contents
  37. function SentryAppExternalInstallationContent({params, ...props}: Props) {
  38. const api = useApi();
  39. // The selected organization fetched from org details
  40. const [organization, setOrganization] = useState<Organization>();
  41. // The selected organization's slug. Should be removed as we have the selected organization as well.
  42. const [selectedOrgSlug, setSelectedOrgSlug] = useState<string>();
  43. const [organizations, setOrganizations] = useState<OrganizationSummary[]>([]);
  44. const [orgsLoading, setOrgsLoading] = useState<boolean>(true);
  45. const [isInstalled, setIsInstalled] = useState<boolean>();
  46. // Load data on mount.
  47. const {data: sentryApp, isPending: sentryAppLoading} = useApiQuery<SentryApp>(
  48. [`/sentry-apps/${params.sentryAppSlug}/`],
  49. {
  50. staleTime: 0,
  51. }
  52. );
  53. useEffect(
  54. function () {
  55. async function loadOrgs() {
  56. try {
  57. const orgs = await fetchOrganizations(api);
  58. setOrganizations(orgs);
  59. setOrgsLoading(false);
  60. } catch (e) {
  61. setOrgsLoading(false);
  62. // Do nothing.
  63. }
  64. }
  65. loadOrgs();
  66. },
  67. [api]
  68. );
  69. const onSelectOrg = useCallback(
  70. async function (orgSlug: string) {
  71. const customerDomain = ConfigStore.get('customerDomain');
  72. // redirect to the org if it's different than the org being selected
  73. if (customerDomain?.subdomain && orgSlug !== customerDomain?.subdomain) {
  74. const urlWithQuery = generateOrgSlugUrl(orgSlug) + props.location.search;
  75. window.location.assign(urlWithQuery);
  76. return;
  77. }
  78. // otherwise proceed as normal
  79. setSelectedOrgSlug(orgSlug);
  80. try {
  81. const [org, installations]: [Organization, SentryAppInstallation[]] =
  82. await Promise.all([
  83. api.requestPromise(`/organizations/${orgSlug}/`, {
  84. query: {
  85. include_feature_flags: 1,
  86. },
  87. }),
  88. api.requestPromise(`/organizations/${orgSlug}/sentry-app-installations/`),
  89. ]);
  90. const installed = installations
  91. .map(install => install.app.slug)
  92. .includes(params.sentryAppSlug);
  93. setOrganization(org);
  94. setSelectedOrgSlug(org.slug);
  95. setIsInstalled(installed);
  96. } catch (err) {
  97. addErrorMessage(t('Failed to retrieve organization or integration details'));
  98. }
  99. },
  100. [
  101. api,
  102. params.sentryAppSlug,
  103. props.location.search,
  104. setOrganization,
  105. setSelectedOrgSlug,
  106. setIsInstalled,
  107. ]
  108. );
  109. useEffect(function () {
  110. // Skip if we have a selected org, or if there aren't any orgs loaded yet.
  111. if (organization || organizations.length < 1) {
  112. return;
  113. }
  114. if (organizations.length === 1) {
  115. // auto select the org if there is only one
  116. onSelectOrg(organizations[0]!.slug);
  117. }
  118. // now check the subomdain and use that org slug if it exists
  119. const customerDomain = ConfigStore.get('customerDomain');
  120. if (customerDomain?.subdomain) {
  121. onSelectOrg(customerDomain.subdomain);
  122. }
  123. });
  124. const onClose = useCallback(() => {
  125. // if we came from somewhere, go back there. Otherwise, back to the integrations page
  126. const newUrl = document.referrer || `/settings/${selectedOrgSlug}/integrations/`;
  127. window.location.assign(newUrl);
  128. }, [selectedOrgSlug]);
  129. const disableInstall = useCallback(
  130. function () {
  131. if (!(sentryApp && selectedOrgSlug)) {
  132. return false;
  133. }
  134. return isInstalled || isSentryAppUnavailableForOrg(sentryApp, selectedOrgSlug);
  135. },
  136. [isInstalled, selectedOrgSlug, sentryApp]
  137. );
  138. const onInstall = useCallback(async (): Promise<any | undefined> => {
  139. if (!organization || !sentryApp) {
  140. return undefined;
  141. }
  142. trackIntegrationAnalytics('integrations.installation_start', {
  143. integration_type: 'sentry_app',
  144. integration: sentryApp.slug,
  145. view: 'external_install',
  146. integration_status: sentryApp.status,
  147. organization,
  148. });
  149. const install = await installSentryApp(api, organization.slug, sentryApp);
  150. // installation is complete if the status is installed
  151. if (install.status === 'installed') {
  152. trackIntegrationAnalytics('integrations.installation_complete', {
  153. integration_type: 'sentry_app',
  154. integration: sentryApp.slug,
  155. view: 'external_install',
  156. integration_status: sentryApp.status,
  157. organization,
  158. });
  159. }
  160. if (sentryApp.redirectUrl) {
  161. const queryParams: Record<string, string | undefined> = {
  162. installationId: install.uuid,
  163. code: install.code,
  164. orgSlug: organization.slug,
  165. };
  166. const state = props.location.query.state;
  167. if (state) {
  168. queryParams.state = state;
  169. }
  170. const redirectUrl = addQueryParamsToExistingUrl(sentryApp.redirectUrl, queryParams);
  171. return window.location.assign(redirectUrl);
  172. }
  173. return onClose();
  174. }, [api, organization, sentryApp, onClose, props.location.query.state]);
  175. if (sentryAppLoading || orgsLoading || !sentryApp) {
  176. return <LoadingIndicator />;
  177. }
  178. return (
  179. <div>
  180. <OrgViewHolder>
  181. {isSingleOrg(organizations) ? (
  182. <SingleOrgView organizations={organizations} sentryApp={sentryApp} />
  183. ) : (
  184. <MultiOrgView
  185. onSelectOrg={onSelectOrg}
  186. organizations={organizations}
  187. selectedOrgSlug={selectedOrgSlug}
  188. sentryApp={sentryApp}
  189. />
  190. )}
  191. </OrgViewHolder>
  192. <CheckAndRenderError
  193. organization={organization}
  194. selectedOrgSlug={selectedOrgSlug}
  195. isInstalled={isInstalled}
  196. sentryApp={sentryApp}
  197. />
  198. {organization && (
  199. <OrganizationContext.Provider value={organization}>
  200. <SentryAppDetailsModal
  201. sentryApp={sentryApp}
  202. organization={organization}
  203. onInstall={onInstall}
  204. closeModal={onClose}
  205. isInstalled={disableInstall()}
  206. />
  207. </OrganizationContext.Provider>
  208. )}
  209. </div>
  210. );
  211. }
  212. type CheckAndRenderProps = {
  213. isInstalled: boolean | undefined;
  214. organization: Organization | undefined;
  215. selectedOrgSlug: string | undefined;
  216. sentryApp: SentryApp;
  217. };
  218. function CheckAndRenderError({
  219. organization,
  220. selectedOrgSlug,
  221. isInstalled,
  222. sentryApp,
  223. }: CheckAndRenderProps) {
  224. if (selectedOrgSlug && organization && !hasAccess(organization)) {
  225. return (
  226. <Alert.Container>
  227. <Alert type="error" showIcon>
  228. <p>
  229. {tct(
  230. `You do not have permission to install integrations in
  231. [organization]. Ask an organization owner or manager to
  232. visit this page to finish installing this integration.`,
  233. {organization: <strong>{organization.slug}</strong>}
  234. )}
  235. </p>
  236. <InstallLink>{generateOrgSlugUrl(selectedOrgSlug)}</InstallLink>
  237. </Alert>
  238. </Alert.Container>
  239. );
  240. }
  241. if (isInstalled && organization && sentryApp) {
  242. return (
  243. <Alert.Container>
  244. <Alert type="error" showIcon>
  245. {tct('Integration [sentryAppName] already installed for [organization]', {
  246. organization: <strong>{organization.name}</strong>,
  247. sentryAppName: <strong>{sentryApp.name}</strong>,
  248. })}
  249. </Alert>
  250. </Alert.Container>
  251. );
  252. }
  253. if (isSentryAppUnavailableForOrg(sentryApp, selectedOrgSlug)) {
  254. // use the slug of the owner if we have it, otherwise use 'another organization'
  255. const ownerSlug = sentryApp?.owner?.slug ?? 'another organization';
  256. return (
  257. <Alert.Container>
  258. <Alert type="error" showIcon>
  259. {tct(
  260. 'Integration [sentryAppName] is an unpublished integration for [otherOrg]. An unpublished integration can only be installed on the organization which created it.',
  261. {
  262. sentryAppName: <strong>{sentryApp.name}</strong>,
  263. otherOrg: <strong>{ownerSlug}</strong>,
  264. }
  265. )}
  266. </Alert>
  267. </Alert.Container>
  268. );
  269. }
  270. return null;
  271. }
  272. type SingleOrgProps = {
  273. organizations: OrganizationSummary[];
  274. sentryApp: SentryApp;
  275. };
  276. function SingleOrgView({organizations, sentryApp}: SingleOrgProps) {
  277. const organizationName = organizations[0]!.name;
  278. return (
  279. <div>
  280. <p>
  281. {tct('You are installing [sentryAppName] for organization [organization]', {
  282. organization: <strong>{organizationName}</strong>,
  283. sentryAppName: <strong>{sentryApp.name}</strong>,
  284. })}
  285. </p>
  286. </div>
  287. );
  288. }
  289. type SelectOrgCallback = (slug: string) => void;
  290. type MultiOrgProps = {
  291. onSelectOrg: SelectOrgCallback;
  292. organizations: OrganizationSummary[];
  293. selectedOrgSlug: string | undefined;
  294. sentryApp: SentryApp;
  295. };
  296. function MultiOrgView({
  297. onSelectOrg,
  298. organizations,
  299. selectedOrgSlug,
  300. sentryApp,
  301. }: MultiOrgProps) {
  302. return (
  303. <div>
  304. <p>
  305. {tct(
  306. 'Please pick a specific [organization:organization] to install [sentryAppName]',
  307. {
  308. organization: <strong />,
  309. sentryAppName: <strong>{sentryApp.name}</strong>,
  310. }
  311. )}
  312. </p>
  313. <FieldGroup label={t('Organization')} inline={false} stacked required>
  314. <Select
  315. onChange={({value}: any) => onSelectOrg(value)}
  316. value={selectedOrgSlug}
  317. placeholder={t('Select an organization')}
  318. options={getOrganizationOptions(organizations)}
  319. data-test-id="org-select"
  320. />
  321. </FieldGroup>
  322. </div>
  323. );
  324. }
  325. const hasAccess = (org: Organization) => org.access.includes('org:integrations');
  326. function isSingleOrg(organizations: OrganizationSummary[]): boolean {
  327. return organizations.length === 1;
  328. }
  329. function getOrganizationOptions(organizations: OrganizationSummary[]) {
  330. return organizations.map(org => ({
  331. value: org.slug,
  332. label: (
  333. <div key={org.slug}>
  334. <OrganizationAvatar organization={org} />
  335. <OrgNameHolder>{org.slug}</OrgNameHolder>
  336. </div>
  337. ),
  338. }));
  339. }
  340. function isSentryAppUnavailableForOrg(
  341. sentryApp: SentryApp,
  342. selectedOrgSlug: string | undefined
  343. ): boolean {
  344. if (!selectedOrgSlug) {
  345. return false;
  346. }
  347. // if the app is unpublished for a different org
  348. return sentryApp?.owner?.slug !== selectedOrgSlug && sentryApp.status === 'unpublished';
  349. }
  350. const InstallLink = styled('pre')`
  351. margin-bottom: 0;
  352. background: #fbe3e1;
  353. `;
  354. const OrgNameHolder = styled('span')`
  355. margin-left: 5px;
  356. `;
  357. const Content = styled('div')`
  358. margin-bottom: 40px;
  359. `;
  360. const OrgViewHolder = styled('div')`
  361. margin-bottom: 20px;
  362. `;