123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320 |
- import {RouteComponentProps} from 'react-router';
- import styled from '@emotion/styled';
- import {addErrorMessage} from 'sentry/actionCreators/indicator';
- import {installSentryApp} from 'sentry/actionCreators/sentryAppInstallations';
- import Alert from 'sentry/components/alert';
- import OrganizationAvatar from 'sentry/components/avatar/organizationAvatar';
- import SelectControl from 'sentry/components/forms/controls/selectControl';
- import Field from 'sentry/components/forms/field';
- 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 {trackIntegrationAnalytics} from 'sentry/utils/integrationUtil';
- import {addQueryParamsToExistingUrl} from 'sentry/utils/queryString';
- import AsyncView from 'sentry/views/asyncView';
- type Props = RouteComponentProps<{sentryAppSlug: string}, {}>;
- type State = AsyncView['state'] & {
- organization: Organization | null;
- organizations: Organization[];
- reloading: boolean;
- selectedOrgSlug: string | null;
- sentryApp: SentryApp;
- };
- export default class SentryAppExternalInstallation extends AsyncView<Props, State> {
- disableErrorReport = false;
- getDefaultState() {
- const state = super.getDefaultState();
- return {
- ...state,
- selectedOrgSlug: null,
- organization: null,
- organizations: [],
- reloading: false,
- };
- }
- getEndpoints(): ReturnType<AsyncView['getEndpoints']> {
- 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<any | undefined> => {
- 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.api, 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.api.requestPromise(`/organizations/${orgSlug}/`),
- this.api.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: (
- <div key={org.slug}>
- <OrganizationAvatar organization={org} />
- <OrgNameHolder>{org.slug}</OrgNameHolder>
- </div>
- ),
- }));
- }
- renderInternalAppError() {
- const {sentryApp} = this.state;
- return (
- <Alert type="error" showIcon>
- {tct(
- 'Integration [sentryAppName] is an internal integration. Internal integrations are automatically installed',
- {
- sentryAppName: <strong>{sentryApp.name}</strong>,
- }
- )}
- </Alert>
- );
- }
- checkAndRenderError() {
- const {organization, selectedOrgSlug, isInstalled, sentryApp} = this.state;
- if (selectedOrgSlug && organization && !this.hasAccess(organization)) {
- return (
- <Alert type="error" showIcon>
- <p>
- {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: <strong>{organization.slug}</strong>}
- )}
- </p>
- <InstallLink>{window.location.href}</InstallLink>
- </Alert>
- );
- }
- if (isInstalled && organization) {
- return (
- <Alert type="error" showIcon>
- {tct('Integration [sentryAppName] already installed for [organization]', {
- organization: <strong>{organization.name}</strong>,
- sentryAppName: <strong>{sentryApp.name}</strong>,
- })}
- </Alert>
- );
- }
- 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 (
- <Alert type="error" showIcon>
- {tct(
- 'Integration [sentryAppName] is an unpublished integration for [otherOrg]. An unpublished integration can only be installed on the organization which created it.',
- {
- sentryAppName: <strong>{sentryApp.name}</strong>,
- otherOrg: <strong>{ownerSlug}</strong>,
- }
- )}
- </Alert>
- );
- }
- return null;
- }
- renderMultiOrgView() {
- const {selectedOrgSlug, sentryApp} = this.state;
- return (
- <div>
- <p>
- {tct(
- 'Please pick a specific [organization:organization] to install [sentryAppName]',
- {
- organization: <strong />,
- sentryAppName: <strong>{sentryApp.name}</strong>,
- }
- )}
- </p>
- <Field label={t('Organization')} inline={false} stacked required>
- {() => (
- <SelectControl
- onChange={({value}) => this.onSelectOrg(value)}
- value={selectedOrgSlug}
- placeholder={t('Select an organization')}
- options={this.getOptions()}
- />
- )}
- </Field>
- </div>
- );
- }
- 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 (
- <div>
- <p>
- {tct('You are installing [sentryAppName] for organization [organization]', {
- organization: <strong>{organizationName}</strong>,
- sentryAppName: <strong>{sentryApp.name}</strong>,
- })}
- </p>
- </div>
- );
- }
- renderMainContent() {
- const {organization, sentryApp} = this.state;
- return (
- <div>
- <OrgViewHolder>
- {this.isSingleOrg ? this.renderSingleOrgView() : this.renderMultiOrgView()}
- </OrgViewHolder>
- {this.checkAndRenderError()}
- {organization && (
- <SentryAppDetailsModal
- sentryApp={sentryApp}
- organization={organization}
- onInstall={this.onInstall}
- closeModal={this.onClose}
- isInstalled={this.disableInstall}
- />
- )}
- </div>
- );
- }
- renderBody() {
- return (
- <NarrowLayout>
- <Content>
- <h3>{t('Finish integration installation')}</h3>
- {this.isSentryAppInternal
- ? this.renderInternalAppError()
- : this.renderMainContent()}
- </Content>
- </NarrowLayout>
- );
- }
- }
- 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;
- `;
|