Files
2024-02-15 17:01:50 +00:00

329 lines
10 KiB
TypeScript

import type {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 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 type {Organization, SentryApp, SentryAppInstallation} from 'sentry/types';
import {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;
getDefaultState() {
const state = super.getDefaultState();
return {
...state,
selectedOrgSlug: null,
organization: null,
organizations: [],
reloading: false,
};
}
getEndpoints(): ReturnType<DeprecatedAsyncView['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>{generateOrgSlugUrl(selectedOrgSlug)}</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>
<FieldGroup label={t('Organization')} inline={false} stacked required>
{() => (
<SelectControl
onChange={({value}) => this.onSelectOrg(value)}
value={selectedOrgSlug}
placeholder={t('Select an organization')}
options={this.getOptions()}
/>
)}
</FieldGroup>
</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 && (
<OrganizationContext.Provider value={organization}>
<SentryAppDetailsModal
sentryApp={sentryApp}
organization={organization}
onInstall={this.onInstall}
closeModal={this.onClose}
isInstalled={this.disableInstall}
/>
</OrganizationContext.Provider>
)}
</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;
`;