Files
zeekayandHanzo Dev f888991a7a drop the Sentry wordmark from the react app
FSL-1.0-Apache-2.0 grants us the code, not the name. "Sentry" is
Functional Software's trademark, so every place the running app says it
to a user -- page titles, onboarding, empty states, toasts, settings
copy, the self-hosted footer -- now says Sentinel.

Attribution is the other half of the deal and stays untouched: upstream
copyright headers, LICENSE, NOTICE. So does anything where the mark is a
fact rather than a brand -- @sentry/* packages, sentry-cli, the wizard
and bundler plugins, docs.sentry.io, the whole gettingStartedDocs tree.
Renaming those would leave instructions that no longer work.

Nothing structural moved. No import, identifier, route path, feature
flag, API field or test id changed; the diff is 312 lines in and 312
out. Colocated specs were updated in lockstep with the copy they assert.
Marketing and community links point at hanzo.ai, only where a real page
exists to point at.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-10 18:01:22 -07:00

421 lines
13 KiB
TypeScript

import {Fragment} from 'react';
import type {RouteComponentProps} from 'react-router';
import styled from '@emotion/styled';
import {urlEncode} from '@sentry/utils';
import {addErrorMessage} from 'sentry/actionCreators/indicator';
import {Alert} from 'sentry/components/alert';
import {Button} from 'sentry/components/button';
import SelectControl from 'sentry/components/forms/controls/selectControl';
import FieldGroup from 'sentry/components/forms/fieldGroup';
import IdBadge from 'sentry/components/idBadge';
import ExternalLink from 'sentry/components/links/externalLink';
import LoadingIndicator from 'sentry/components/loadingIndicator';
import NarrowLayout from 'sentry/components/narrowLayout';
import {t, tct} from 'sentry/locale';
import ConfigStore from 'sentry/stores/configStore';
import type {Integration, IntegrationProvider, Organization} from 'sentry/types';
import {generateOrgSlugUrl} from 'sentry/utils';
import type {IntegrationAnalyticsKey} from 'sentry/utils/analytics/integrations';
import {
getIntegrationFeatureGate,
trackIntegrationAnalytics,
} from 'sentry/utils/integrationUtil';
import {singleLineRenderer} from 'sentry/utils/marked';
import {normalizeUrl} from 'sentry/utils/withDomainRequired';
import DeprecatedAsyncView from 'sentry/views/deprecatedAsyncView';
import {DisabledNotice} from 'sentry/views/settings/organizationIntegrations/abstractIntegrationDetailedView';
import AddIntegration from 'sentry/views/settings/organizationIntegrations/addIntegration';
// installationId present for Github flow
type Props = RouteComponentProps<{integrationSlug: string; installationId?: string}, {}>;
type State = DeprecatedAsyncView['state'] & {
installationData?: GitHubIntegrationInstallation;
installationDataLoading?: boolean;
organization?: Organization;
provider?: IntegrationProvider;
selectedOrgSlug?: string;
};
interface GitHubIntegrationInstallation {
account: {
login: string;
type: string;
};
sender: {
id: number;
login: string;
};
}
export default class IntegrationOrganizationLink extends DeprecatedAsyncView<
Props,
State
> {
disableErrorReport = false;
getEndpoints(): ReturnType<DeprecatedAsyncView['getEndpoints']> {
return [['organizations', '/organizations/']];
}
getTitle() {
return t('Choose Installation Organization');
}
trackIntegrationAnalytics = (
eventName: IntegrationAnalyticsKey,
startSession?: boolean
) => {
const {organization, provider} = this.state;
// should have these set but need to make TS happy
if (!organization || !provider) {
return;
}
trackIntegrationAnalytics(
eventName,
{
integration_type: 'first_party',
integration: provider.key,
// We actually don't know if it's installed but neither does the user in the view and multiple installs is possible
already_installed: false,
view: 'external_install',
organization,
},
{startSession: !!startSession}
);
};
trackOpened() {
this.trackIntegrationAnalytics('integrations.integration_viewed', true);
}
trackInstallationStart() {
this.trackIntegrationAnalytics('integrations.installation_start');
}
get integrationSlug() {
return this.props.params.integrationSlug;
}
get queryParams() {
return this.props.location.query;
}
getOrgBySlug = (orgSlug: string): Organization | undefined => {
return this.state.organizations.find((org: Organization) => org.slug === orgSlug);
};
onLoadAllEndpointsSuccess() {
// auto select the org if there is only one
const {organizations} = this.state;
if (organizations.length === 1) {
this.onSelectOrg(organizations[0].slug);
}
// now check the subomdain and use that org slug if it exists
const customerDomain = ConfigStore.get('customerDomain');
if (customerDomain?.subdomain) {
this.onSelectOrg(customerDomain.subdomain);
}
}
onSelectOrg = async (orgSlug: string) => {
const customerDomain = ConfigStore.get('customerDomain');
// redirect to the org if it's different than the org being selected
if (customerDomain?.subdomain && orgSlug !== customerDomain?.subdomain) {
const urlWithQuery = generateOrgSlugUrl(orgSlug) + this.props.location.search;
window.location.assign(urlWithQuery);
return;
}
// otherwise proceed as normal
this.setState({selectedOrgSlug: orgSlug, reloading: true, organization: undefined});
try {
const [organization, {providers}]: [
Organization,
{providers: IntegrationProvider[]},
] = await Promise.all([
this.api.requestPromise(`/organizations/${orgSlug}/`),
this.api.requestPromise(
`/organizations/${orgSlug}/config/integrations/?provider_key=${this.integrationSlug}`
),
]);
// should never happen with a valid provider
if (providers.length === 0) {
throw new Error('Invalid provider');
}
let installationData = undefined;
if (this.integrationSlug === 'github') {
const {installationId} = this.props.params;
try {
// The API endpoint /extensions/github/installation is not prefixed with /api/0
// so we have to use this workaround.
installationData = await this.api.requestPromise(
`/../../extensions/github/installation/${installationId}/`
);
} catch (_err) {
addErrorMessage(t('Failed to retrieve GitHub installation details'));
}
this.setState({installationDataLoading: false});
}
this.setState(
{organization, reloading: false, provider: providers[0], installationData},
this.trackOpened
);
} catch (_err) {
addErrorMessage(t('Failed to retrieve organization or integration details'));
this.setState({reloading: false});
}
};
hasAccess = () => {
const {organization} = this.state;
return organization?.access.includes('org:integrations');
};
// used with Github to redirect to the the integration detail
onInstallWithInstallationId = (data: Integration) => {
const {organization} = this.state;
const orgId = organization?.slug;
const normalizedUrl = normalizeUrl(
`/settings/${orgId}/integrations/${data.provider.key}/${data.id}/`
);
window.location.assign(
`${organization?.links.organizationUrl || ''}${normalizedUrl}`
);
};
// non-Github redirects to the extension view where the backend will finish the installation
finishInstallation = () => {
// add the selected org to the query parameters and then redirect back to configure
const {selectedOrgSlug, organization} = this.state;
const query = {orgSlug: selectedOrgSlug, ...this.queryParams};
this.trackInstallationStart();
// need to send to control silo to finish the installation
window.location.assign(
`${organization?.links.organizationUrl || ''}/extensions/${
this.integrationSlug
}/configure/?${urlEncode(query)}`
);
};
renderAddButton() {
const {installationId} = this.props.params;
const {organization, provider} = this.state;
// should never happen but we need this check for TS
if (!provider || !organization) {
return null;
}
const {features} = provider.metadata;
// Prepare the features list
const featuresComponents = features.map(f => ({
featureGate: f.featureGate,
description: (
<FeatureListItem
dangerouslySetInnerHTML={{__html: singleLineRenderer(f.description)}}
/>
),
}));
const {IntegrationFeatures} = getIntegrationFeatureGate();
// Github uses a different installation flow with the installationId as a parameter
// We have to wrap our installation button with AddIntegration so we can get the
// addIntegrationWithInstallationId callback.
// if we don't have an installationId, we need to use the finishInstallation callback.
return (
<IntegrationFeatures organization={organization} features={featuresComponents}>
{({disabled, disabledReason}) => (
<AddIntegration
provider={provider}
onInstall={this.onInstallWithInstallationId}
organization={organization}
>
{addIntegrationWithInstallationId => (
<ButtonWrapper>
<Button
priority="primary"
disabled={!this.hasAccess() || disabled}
onClick={() =>
installationId
? addIntegrationWithInstallationId({
installation_id: installationId,
})
: this.finishInstallation()
}
>
{t('Install %s', provider.name)}
</Button>
{disabled && <DisabledNotice reason={disabledReason} />}
</ButtonWrapper>
)}
</AddIntegration>
)}
</IntegrationFeatures>
);
}
renderBottom() {
const {organization, selectedOrgSlug, provider, reloading} = this.state;
const {FeatureList} = getIntegrationFeatureGate();
if (reloading) {
return <LoadingIndicator />;
}
return (
<Fragment>
{selectedOrgSlug && organization && !this.hasAccess() && (
<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>
)}
{provider && organization && this.hasAccess() && FeatureList && (
<Fragment>
<p>
{tct(
'The following features will be available for [organization] when installed.',
{organization: <strong>{organization.slug}</strong>}
)}
</p>
<FeatureList
organization={organization}
features={provider.metadata.features}
provider={provider}
/>
</Fragment>
)}
<div className="form-actions">{this.renderAddButton()}</div>
</Fragment>
);
}
renderCallout() {
const {installationData, installationDataLoading} = this.state;
if (this.integrationSlug !== 'github') {
return null;
}
if (!installationData) {
if (installationDataLoading !== false) {
return null;
}
return (
<Alert type="warning" showIcon>
{t(
'We could not verify the authenticity of the installation request. We recommend restarting the installation process.'
)}
</Alert>
);
}
const sender_url = `https://github.com/${installationData?.sender.login}`;
const target_url = `https://github.com/${installationData?.account.login}`;
const alertText = tct(
`GitHub user [sender_login] has installed GitHub app to [account_type] [account_login]. Proceed if you want to attach this installation to your Sentinel account.`,
{
account_type: <strong>{installationData?.account.type}</strong>,
account_login: (
<strong>
<ExternalLink href={target_url}>
{installationData?.account.login}
</ExternalLink>
</strong>
),
sender_id: <strong>{installationData?.sender.id}</strong>,
sender_login: (
<strong>
<ExternalLink href={sender_url}>
{installationData?.sender.login}
</ExternalLink>
</strong>
),
}
);
return (
<Alert type="info" showIcon>
{alertText}
</Alert>
);
}
renderBody() {
const {selectedOrgSlug} = this.state;
const options = this.state.organizations.map((org: Organization) => ({
value: org.slug,
label: (
<IdBadge
organization={org}
avatarSize={20}
displayName={org.name}
avatarProps={{consistentWidth: true}}
/>
),
}));
return (
<NarrowLayout>
<h3>{t('Finish integration installation')}</h3>
{this.renderCallout()}
<p>
{tct(
`Please pick a specific [organization:organization] to link with
your integration installation of [integation].`,
{
organization: <strong />,
integation: <strong>{this.integrationSlug}</strong>,
}
)}
</p>
<FieldGroup label={t('Organization')} inline={false} stacked required>
<SelectControl
onChange={({value: orgSlug}) => this.onSelectOrg(orgSlug)}
value={selectedOrgSlug}
placeholder={t('Select an organization')}
options={options}
/>
</FieldGroup>
{this.renderBottom()}
</NarrowLayout>
);
}
}
const InstallLink = styled('pre')`
margin-bottom: 0;
background: #fbe3e1;
`;
const FeatureListItem = styled('span')`
line-height: 24px;
`;
const ButtonWrapper = styled('div')`
margin-left: auto;
align-self: center;
display: flex;
flex-direction: column;
align-items: center;
`;