import {Fragment} from 'react'; import type {RouteComponentProps} from 'react-router'; import {browserHistory} from 'react-router'; import styled from '@emotion/styled'; import omit from 'lodash/omit'; import {Observer} from 'mobx-react'; import scrollToElement from 'scroll-to-element'; import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator'; import { addSentryAppToken, removeSentryAppToken, } from 'sentry/actionCreators/sentryAppTokens'; import Avatar from 'sentry/components/avatar'; import type {Model} from 'sentry/components/avatarChooser'; import AvatarChooser from 'sentry/components/avatarChooser'; import {Button} from 'sentry/components/button'; import EmptyMessage from 'sentry/components/emptyMessage'; import Form from 'sentry/components/forms/form'; import FormField from 'sentry/components/forms/formField'; import JsonForm from 'sentry/components/forms/jsonForm'; import type {FieldValue} from 'sentry/components/forms/model'; import FormModel from 'sentry/components/forms/model'; import ExternalLink from 'sentry/components/links/externalLink'; import Panel from 'sentry/components/panels/panel'; import PanelBody from 'sentry/components/panels/panelBody'; import PanelHeader from 'sentry/components/panels/panelHeader'; import TextCopyInput from 'sentry/components/textCopyInput'; import {Tooltip} from 'sentry/components/tooltip'; import {SENTRY_APP_PERMISSIONS} from 'sentry/constants'; import { internalIntegrationForms, publicIntegrationForms, } from 'sentry/data/forms/sentryApplication'; import {IconAdd} from 'sentry/icons'; import {t, tct} from 'sentry/locale'; import {space} from 'sentry/styles/space'; import type { InternalAppApiToken, NewInternalAppApiToken, Organization, Scope, SentryApp, } from 'sentry/types'; import getDynamicText from 'sentry/utils/getDynamicText'; import {normalizeUrl} from 'sentry/utils/withDomainRequired'; import withOrganization from 'sentry/utils/withOrganization'; import DeprecatedAsyncView from 'sentry/views/deprecatedAsyncView'; import ApiTokenRow from 'sentry/views/settings/account/apiTokenRow'; import NewTokenHandler from 'sentry/views/settings/components/newTokenHandler'; import SettingsPageHeader from 'sentry/views/settings/components/settingsPageHeader'; import PermissionsObserver from 'sentry/views/settings/organizationDeveloperSettings/permissionsObserver'; type Resource = 'Project' | 'Team' | 'Release' | 'Event' | 'Organization' | 'Member'; const AVATAR_STYLES = { color: { size: 50, title: t('Default Logo'), previewText: t('The default icon for integrations'), help: t('Image must be between 256px by 256px and 1024px by 1024px.'), }, simple: { size: 20, title: t('Default Icon'), previewText: tct('This is a silhouette icon used only for [uiDocs:UI Components]', { uiDocs: ( ), }), help: t( 'Image must be between 256px by 256px and 1024px by 1024px, and may only use black and transparent pixels.' ), }, }; /** * Finds the resource in SENTRY_APP_PERMISSIONS that contains a given scope * We should always find a match unless there is a bug * @param {Scope} scope * @return {Resource | undefined} */ const getResourceFromScope = (scope: Scope): Resource | undefined => { for (const permObj of SENTRY_APP_PERMISSIONS) { const allChoices = Object.values(permObj.choices); const allScopes = allChoices.reduce( (_allScopes: string[], choice) => _allScopes.concat(choice?.scopes ?? []), [] ); if (allScopes.includes(scope)) { return permObj.resource as Resource; } } return undefined; }; /** * We need to map the API response errors to the actual form fields. * We do this by pulling out scopes and mapping each scope error to the correct input. * @param {Object} responseJSON */ const mapFormErrors = (responseJSON?: any) => { if (!responseJSON) { return responseJSON; } const formErrors = omit(responseJSON, ['scopes']); if (responseJSON.scopes) { responseJSON.scopes.forEach((message: string) => { // find the scope from the error message of a specific format const matches = message.match(/Requested permission of (\w+:\w+)/); if (matches) { const scope = matches[1]; const resource = getResourceFromScope(scope as Scope); // should always match but technically resource can be undefined if (resource) { formErrors[`${resource}--permission`] = [message]; } } }); } return formErrors; }; class SentryAppFormModel extends FormModel { /** * Filter out Permission input field values. * * Permissions (API Scopes) are presented as a list of SelectFields. * Instead of them being submitted individually, we want them rolled * up into a single list of scopes (this is done in `PermissionSelection`). * * Because they are all individual inputs, we end up with attributes * in the JSON we send to the API that we don't want. * * This function filters those attributes out of the data that is * ultimately sent to the API. */ getData() { return this.fields.toJSON().reduce((data, [k, v]) => { if (!k.endsWith('--permission')) { data[k] = v; } return data; }, {}); } } type Props = RouteComponentProps<{appSlug?: string}, {}> & { organization: Organization; }; type State = DeprecatedAsyncView['state'] & { app: SentryApp | null; newTokens: NewInternalAppApiToken[]; tokens: InternalAppApiToken[]; }; class SentryApplicationDetails extends DeprecatedAsyncView { form = new SentryAppFormModel({mapFormErrors}); getDefaultState(): State { return { ...super.getDefaultState(), app: null, tokens: [], newTokens: [], }; } getEndpoints(): ReturnType { const {appSlug} = this.props.params; if (appSlug) { return [ ['app', `/sentry-apps/${appSlug}/`], ['tokens', `/sentry-apps/${appSlug}/api-tokens/`], ]; } return []; } getHeaderTitle() { const {app} = this.state; const action = app ? 'Edit' : 'Create'; const type = this.isInternal ? 'Internal' : 'Public'; return tct('[action] [type] Integration', {action, type}); } // Events may come from the API as "issue.created" when we just want "issue" here. normalize(events) { if (events.length === 0) { return events; } return events.map(e => e.split('.').shift()); } handleSubmitSuccess = (data: SentryApp) => { const {app} = this.state; const {organization} = this.props; const type = this.isInternal ? 'internal' : 'public'; const baseUrl = `/settings/${organization.slug}/developer-settings/`; const url = app ? `${baseUrl}?type=${type}` : `${baseUrl}${data.slug}/`; if (app) { addSuccessMessage(t('%s successfully saved.', data.name)); } else { addSuccessMessage(t('%s successfully created.', data.name)); } browserHistory.push(normalizeUrl(url)); }; handleSubmitError = err => { let errorMessage = t('Unknown Error'); if (err.status >= 400 && err.status < 500) { errorMessage = err?.responseJSON.detail ?? errorMessage; } addErrorMessage(errorMessage); if (this.form.formErrors) { const firstErrorFieldId = Object.keys(this.form.formErrors)[0]; if (firstErrorFieldId) { scrollToElement(`#${firstErrorFieldId}`, { align: 'middle', offset: 0, }); } } }; get isInternal() { const {app} = this.state; if (app) { // if we are editing an existing app, check the status of the app return app.status === 'internal'; } return this.props.route.path === 'new-internal/'; } get showAuthInfo() { const {app} = this.state; return !(app?.clientSecret && app.clientSecret[0] === '*'); } onAddToken = async (evt: React.MouseEvent): Promise => { evt.preventDefault(); const {app, newTokens} = this.state; if (!app) { return; } const api = this.api; const token = await addSentryAppToken(api, app); const updatedNewTokens = newTokens.concat(token); this.setState({newTokens: updatedNewTokens}); }; onRemoveToken = async (token: InternalAppApiToken) => { const {app, tokens} = this.state; if (!app) { return; } const api = this.api; const newTokens = tokens.filter(tok => tok.id !== token.id); await removeSentryAppToken(api, app, token.id); this.setState({tokens: newTokens}); }; handleFinishNewToken = (newToken: NewInternalAppApiToken) => { const {tokens, newTokens} = this.state; const updatedNewTokens = newTokens.filter(token => token.id !== newToken.id); const updatedTokens = tokens.concat(newToken as InternalAppApiToken); this.setState({tokens: updatedTokens, newTokens: updatedNewTokens}); }; renderTokens = () => { const {tokens, newTokens} = this.state; if (tokens.length < 1 && newTokens.length < 1) { return ; } const tokensToDisplay = tokens.map(token => ( )); tokensToDisplay.push( ...newTokens.map(newToken => ( this.handleFinishNewToken(newToken)} /> )) ); return tokensToDisplay; }; onFieldChange = (name: string, value: FieldValue): void => { if (name === 'webhookUrl' && !value && this.isInternal) { // if no webhook, then set isAlertable to false this.form.setValue('isAlertable', false); } }; addAvatar = ({avatar}: Model) => { const {app} = this.state; if (app && avatar) { const avatars = app?.avatars?.filter(prevAvatar => prevAvatar.color !== avatar.color) || []; avatars.push(avatar); this.setState({app: {...app, avatars}}); } }; getAvatarModel = (isColor: boolean): Model => { const {app} = this.state; const defaultModel: Model = { avatar: { avatarType: 'default', avatarUuid: null, }, }; if (!app) { return defaultModel; } return { avatar: app?.avatars?.find(({color}) => color === isColor) || defaultModel.avatar, }; }; getAvatarPreview = (isColor: boolean) => { const {app} = this.state; if (!app) { return null; } const avatarStyle = isColor ? 'color' : 'simple'; return ( {AVATAR_STYLES[avatarStyle].title} {AVATAR_STYLES[avatarStyle].previewText} ); }; getAvatarChooser = (isColor: boolean) => { const {app} = this.state; if (!app) { return null; } const avatarStyle = isColor ? 'color' : 'simple'; return ( ); }; renderBody() { const {app} = this.state; const scopes = (app && [...app.scopes]) || []; const events = (app && this.normalize(app.events)) || []; const method = app ? 'PUT' : 'POST'; const endpoint = app ? `/sentry-apps/${app.slug}/` : '/sentry-apps/'; const forms = this.isInternal ? internalIntegrationForms : publicIntegrationForms; let verifyInstall: boolean; if (this.isInternal) { // force verifyInstall to false for all internal apps verifyInstall = false; } else { // use the existing value for verifyInstall if the app exists, otherwise default to true verifyInstall = app ? app.verifyInstall : true; } return (
{() => { const webhookDisabled = this.isInternal && !this.form.getValue('webhookUrl'); return ( {this.getAvatarChooser(true)} {this.getAvatarChooser(false)} ); }} {app && app.status === 'internal' && ( {t('Tokens')} {this.renderTokens()} )} {app && ( {t('Credentials')} {app.status !== 'internal' && ( {({value, id}) => ( {getDynamicText({value, fixed: 'CI_CLIENT_ID'})} )} )} {({value, id}) => value ? ( {getDynamicText({value, fixed: 'CI_CLIENT_SECRET'})} ) : ( hidden ) } )}
); } } export default withOrganization(SentryApplicationDetails); const AvatarPreview = styled('div')` flex: 1; display: grid; grid: 25px 25px / 50px 1fr; `; const StyledPreviewAvatar = styled(Avatar)` grid-area: 1 / 1 / 3 / 2; justify-self: end; `; const AvatarPreviewTitle = styled('span')` display: block; grid-area: 1 / 2 / 2 / 3; padding-left: ${space(2)}; font-weight: bold; `; const AvatarPreviewText = styled('span')` display: block; grid-area: 2 / 2 / 3 / 3; padding-left: ${space(2)}; `;