import {RouteComponentProps} from 'react-router'; import styled from '@emotion/styled'; import {addErrorMessage} from 'sentry/actionCreators/indicator'; import {installSentryApp} from 'sentry/actionCreators/sentryAppInstallations'; import {Client} from 'sentry/api'; import {Alert} from 'sentry/components/alert'; import OrganizationAvatar from 'sentry/components/avatar/organizationAvatar'; import SelectControl from 'sentry/components/forms/controls/selectControl'; import FieldGroup from 'sentry/components/forms/fieldGroup'; import SentryAppDetailsModal from 'sentry/components/modals/sentryAppDetailsModal'; import NarrowLayout from 'sentry/components/narrowLayout'; import {t, tct} from 'sentry/locale'; import {Organization, SentryApp, SentryAppInstallation} from 'sentry/types'; import {generateBaseControlSiloUrl, generateOrgSlugUrl} from 'sentry/utils'; import {trackIntegrationAnalytics} from 'sentry/utils/integrationUtil'; import {addQueryParamsToExistingUrl} from 'sentry/utils/queryString'; import DeprecatedAsyncView from 'sentry/views/deprecatedAsyncView'; import {OrganizationContext} from '../organizationContext'; type Props = RouteComponentProps<{sentryAppSlug: string}, {}>; type State = DeprecatedAsyncView['state'] & { organization: Organization | null; organizations: Organization[]; reloading: boolean; selectedOrgSlug: string | null; sentryApp: SentryApp; }; export default class SentryAppExternalInstallation extends DeprecatedAsyncView< Props, State > { disableErrorReport = false; controlSiloApi = new Client({baseUrl: generateBaseControlSiloUrl() + '/api/0'}); getDefaultState() { const state = super.getDefaultState(); return { ...state, selectedOrgSlug: null, organization: null, organizations: [], reloading: false, }; } getEndpoints(): ReturnType { return [ ['organizations', '/organizations/'], ['sentryApp', `/sentry-apps/${this.sentryAppSlug}/`], ]; } getTitle() { return t('Choose Installation Organization'); } get sentryAppSlug() { return this.props.params.sentryAppSlug; } get isSingleOrg() { return this.state.organizations.length === 1; } get isSentryAppInternal() { const {sentryApp} = this.state; return sentryApp && sentryApp.status === 'internal'; } get isSentryAppUnavailableForOrg() { const {sentryApp, selectedOrgSlug} = this.state; // if the app is unpublished for a different org return ( selectedOrgSlug && sentryApp?.owner?.slug !== selectedOrgSlug && sentryApp.status === 'unpublished' ); } get disableInstall() { const {reloading, isInstalled} = this.state; return isInstalled || reloading || this.isSentryAppUnavailableForOrg; } hasAccess = (org: Organization) => org.access.includes('org:integrations'); onClose = () => { // if we came from somewhere, go back there. Otherwise, back to the integrations page const {selectedOrgSlug} = this.state; const newUrl = document.referrer || `/settings/${selectedOrgSlug}/integrations/`; window.location.assign(newUrl); }; onInstall = async (): Promise => { const {organization, sentryApp} = this.state; if (!organization || !sentryApp) { return undefined; } trackIntegrationAnalytics('integrations.installation_start', { integration_type: 'sentry_app', integration: sentryApp.slug, view: 'external_install', integration_status: sentryApp.status, organization, }); const install = await installSentryApp( this.controlSiloApi, organization.slug, sentryApp ); // installation is complete if the status is installed if (install.status === 'installed') { trackIntegrationAnalytics('integrations.installation_complete', { integration_type: 'sentry_app', integration: sentryApp.slug, view: 'external_install', integration_status: sentryApp.status, organization, }); } if (sentryApp.redirectUrl) { const queryParams = { installationId: install.uuid, code: install.code, orgSlug: organization.slug, }; const redirectUrl = addQueryParamsToExistingUrl(sentryApp.redirectUrl, queryParams); return window.location.assign(redirectUrl); } return this.onClose(); }; onSelectOrg = async (orgSlug: string) => { this.setState({selectedOrgSlug: orgSlug, reloading: true}); try { const [organization, installations]: [Organization, SentryAppInstallation[]] = await Promise.all([ this.controlSiloApi.requestPromise(`/organizations/${orgSlug}/`), this.controlSiloApi.requestPromise( `/organizations/${orgSlug}/sentry-app-installations/` ), ]); const isInstalled = installations .map(install => install.app.slug) .includes(this.sentryAppSlug); // all state fields should be set at the same time so analytics in SentryAppDetailsModal works properly this.setState({organization, isInstalled, reloading: false}); } catch (err) { addErrorMessage(t('Failed to retrieve organization or integration details')); this.setState({reloading: false}); } }; onRequestSuccess = ({stateKey, data}) => { // if only one org, we can immediately update our selected org if (stateKey === 'organizations' && data.length === 1) { this.onSelectOrg(data[0].slug); } }; getOptions() { return this.state.organizations.map(org => ({ value: org.slug, label: (
{org.slug}
), })); } renderInternalAppError() { const {sentryApp} = this.state; return ( {tct( 'Integration [sentryAppName] is an internal integration. Internal integrations are automatically installed', { sentryAppName: {sentryApp.name}, } )} ); } checkAndRenderError() { const {organization, selectedOrgSlug, isInstalled, sentryApp} = this.state; if (selectedOrgSlug && organization && !this.hasAccess(organization)) { return (

{tct( `You do not have permission to install integrations in [organization]. Ask an organization owner or manager to visit this page to finish installing this integration.`, {organization: {organization.slug}} )}

{generateOrgSlugUrl(selectedOrgSlug)}
); } if (isInstalled && organization) { return ( {tct('Integration [sentryAppName] already installed for [organization]', { organization: {organization.name}, sentryAppName: {sentryApp.name}, })} ); } if (this.isSentryAppUnavailableForOrg) { // use the slug of the owner if we have it, otherwise use 'another organization' const ownerSlug = sentryApp?.owner?.slug ?? 'another organization'; return ( {tct( 'Integration [sentryAppName] is an unpublished integration for [otherOrg]. An unpublished integration can only be installed on the organization which created it.', { sentryAppName: {sentryApp.name}, otherOrg: {ownerSlug}, } )} ); } return null; } renderMultiOrgView() { const {selectedOrgSlug, sentryApp} = this.state; return (

{tct( 'Please pick a specific [organization:organization] to install [sentryAppName]', { organization: , sentryAppName: {sentryApp.name}, } )}

{() => ( this.onSelectOrg(value)} value={selectedOrgSlug} placeholder={t('Select an organization')} options={this.getOptions()} /> )}
); } renderSingleOrgView() { const {organizations, sentryApp} = this.state; // pull the name out of organizations since state.organization won't be loaded initially const organizationName = organizations[0].name; return (

{tct('You are installing [sentryAppName] for organization [organization]', { organization: {organizationName}, sentryAppName: {sentryApp.name}, })}

); } renderMainContent() { const {organization, sentryApp} = this.state; return (
{this.isSingleOrg ? this.renderSingleOrgView() : this.renderMultiOrgView()} {this.checkAndRenderError()} {organization && ( )}
); } renderBody() { return (

{t('Finish integration installation')}

{this.isSentryAppInternal ? this.renderInternalAppError() : this.renderMainContent()}
); } } const InstallLink = styled('pre')` margin-bottom: 0; background: #fbe3e1; `; const OrgNameHolder = styled('span')` margin-left: 5px; `; const Content = styled('div')` margin-bottom: 40px; `; const OrgViewHolder = styled('div')` margin-bottom: 20px; `;