import {Fragment} from 'react'; import {browserHistory, RouteComponentProps} 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 AvatarChooser, {Model} from 'sentry/components/avatarChooser'; import Button from 'sentry/components/button'; import DateTime from 'sentry/components/dateTime'; 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 FormModel, {FieldValue} from 'sentry/components/forms/model'; import ExternalLink from 'sentry/components/links/externalLink'; import {Panel, PanelBody, PanelHeader, PanelItem} from 'sentry/components/panels'; 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, IconDelete} from 'sentry/icons'; import {t, tct} from 'sentry/locale'; import space from 'sentry/styles/space'; import {InternalAppApiToken, Scope, SentryApp} from 'sentry/types'; import getDynamicText from 'sentry/utils/getDynamicText'; import AsyncView from 'sentry/views/asyncView'; 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; }; 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; }, {}); } /** * 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 */ 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; } } type Props = RouteComponentProps<{orgId: string; appSlug?: string}, {}>; type State = AsyncView['state'] & { app: SentryApp | null; tokens: InternalAppApiToken[]; }; export default class SentryApplicationDetails extends AsyncView { form = new SentryAppFormModel(); getDefaultState(): State { return { ...super.getDefaultState(), app: null, tokens: [], }; } 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 {orgId} = this.props.params; const type = this.isInternal ? 'internal' : 'public'; const baseUrl = `/settings/${orgId}/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(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 && app.clientSecret && app.clientSecret[0] === '*'); } onAddToken = async (evt: React.MouseEvent): Promise => { evt.preventDefault(); const {app, tokens} = this.state; if (!app) { return; } const api = this.api; const token = await addSentryAppToken(api, app); const newTokens = tokens.concat(token); this.setState({tokens: newTokens}); }; onRemoveToken = async (token: InternalAppApiToken, evt: React.MouseEvent) => { evt.preventDefault(); const {app, tokens} = this.state; if (!app) { return; } const api = this.api; const newTokens = tokens.filter(tok => tok.token !== token.token); await removeSentryAppToken(api, app, token.token); this.setState({tokens: newTokens}); }; renderTokens = () => { const {tokens} = this.state; if (tokens.length > 0) { return tokens.map(token => ( {getDynamicText({value: token.token, fixed: 'xxxxxx'})} Created: )); } return ; }; 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 {orgId} = this.props.params; 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}) => ( {getDynamicText({value, fixed: 'CI_CLIENT_ID'})} )} )} {({value}) => value ? ( {getDynamicText({value, fixed: 'CI_CLIENT_SECRET'})} ) : ( hidden ) } )}
); } } const StyledPanelItem = styled(PanelItem)` display: flex; justify-content: space-between; `; const TokenItem = styled('div')` width: 70%; `; const CreatedTitle = styled('span')` color: ${p => p.theme.gray300}; margin-bottom: 2px; `; const CreatedDate = styled('div')` display: flex; flex-direction: column; font-size: 14px; margin: 0 10px; `; 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)}; `;