import {Fragment, useState} from 'react'; 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 {openModal} from 'sentry/actionCreators/modal'; 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 Confirm from 'sentry/components/confirm'; import {Alert} from 'sentry/components/core/alert'; 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 LoadingError from 'sentry/components/loadingError'; import LoadingIndicator from 'sentry/components/loadingIndicator'; 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 {Scope} from 'sentry/types/core'; import type {SentryApp, SentryAppAvatar} from 'sentry/types/integrations'; import type {RouteComponentProps} from 'sentry/types/legacyReactRouter'; import type {InternalAppApiToken, NewInternalAppApiToken} from 'sentry/types/user'; import getDynamicText from 'sentry/utils/getDynamicText'; import { type ApiQueryKey, setApiQueryData, useApiQuery, useQueryClient, } from 'sentry/utils/queryClient'; import normalizeUrl from 'sentry/utils/url/normalizeUrl'; import useApi from 'sentry/utils/useApi'; import useOrganization from 'sentry/utils/useOrganization'; 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')) { // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message data[k] = v; } return data; }, {}); } } type Props = RouteComponentProps<{appSlug?: string}>; const makeSentryAppQueryKey = (appSlug?: string): ApiQueryKey => { return [`/sentry-apps/${appSlug}/`]; }; const makeSentryAppApiTokensQueryKey = (appSlug?: string): ApiQueryKey => { return [`/sentry-apps/${appSlug}/api-tokens/`]; }; export default function SentryApplicationDetails(props: Props) { const {appSlug} = props.params; const {router, location} = props; const organization = useOrganization(); const [form] = useState( () => new SentryAppFormModel({mapFormErrors}) ); const isEditingApp = !!appSlug; const api = useApi(); const queryClient = useQueryClient(); const SENTRY_APP_QUERY_KEY = makeSentryAppQueryKey(appSlug); const SENTRY_APP_API_TOKENS_QUERY_KEY = makeSentryAppApiTokensQueryKey(appSlug); const { data: app = undefined, isPending, isError, refetch, } = useApiQuery(SENTRY_APP_QUERY_KEY, { staleTime: 30000, enabled: isEditingApp, }); const {data: tokens = []} = useApiQuery( SENTRY_APP_API_TOKENS_QUERY_KEY, { staleTime: 30000, enabled: isEditingApp, } ); const [newTokens, setNewTokens] = useState([]); // Events may come from the API as "issue.created" when we just want "issue" here. const normalize = (events: any) => { if (events.length === 0) { return events; } return events.map((e: any) => e.split('.').shift()); }; const hasTokenAccess = () => { return organization.access.includes('org:write'); }; const isInternal = () => { if (app) { // if we are editing an existing app, check the status of the app return app.status === 'internal'; } return location.pathname.endsWith('new-internal/'); }; const showAuthInfo = () => !(app?.clientSecret && app.clientSecret[0] === '*'); const headerTitle = () => { const action = app ? 'Edit' : 'Create'; const type = isInternal() ? 'Internal' : 'Public'; return tct('[action] [type] Integration', {action, type}); }; const handleSubmitSuccess = (data: SentryApp) => { const type = 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)); refetch(); } else { addSuccessMessage(t('%s successfully created.', data.name)); } router.push(normalizeUrl(url)); }; const handleSubmitError = (err: any) => { let errorMessage = t('Unknown Error'); if (err.status >= 400 && err.status < 500) { errorMessage = err?.responseJSON.detail ?? errorMessage; } addErrorMessage(errorMessage); if (form.formErrors) { const firstErrorFieldId = Object.keys(form.formErrors)[0]; if (firstErrorFieldId) { scrollToElement(`#${firstErrorFieldId}`, { align: 'middle', offset: 0, }); } } }; const onAddToken = async (evt: React.MouseEvent): Promise => { evt.preventDefault(); if (!app) { return; } const token = await addSentryAppToken(api, app); const updatedNewTokens = newTokens.concat(token); setNewTokens(updatedNewTokens); }; const onRemoveToken = async (token: InternalAppApiToken) => { if (!app) { return; } const updatedTokens = tokens.filter(tok => tok.id !== token.id); await removeSentryAppToken(api, app, token.id); setApiQueryData(queryClient, SENTRY_APP_API_TOKENS_QUERY_KEY, updatedTokens); }; const handleFinishNewToken = (newToken: NewInternalAppApiToken) => { const updatedNewTokens = newTokens.filter(token => token.id !== newToken.id); const updatedTokens = tokens.concat(newToken as InternalAppApiToken); setApiQueryData(queryClient, SENTRY_APP_API_TOKENS_QUERY_KEY, updatedTokens); setNewTokens(updatedNewTokens); }; const renderTokens = () => { if (!hasTokenAccess) { return ( ); } if (tokens.length < 1 && newTokens.length < 1) { return ; } const tokensToDisplay = tokens.map(token => ( )); tokensToDisplay.push( ...newTokens.map(newToken => ( handleFinishNewToken(newToken)} /> )) ); return tokensToDisplay; }; const rotateClientSecret = async () => { try { const rotateResponse = await api.requestPromise( `/sentry-apps/${appSlug}/rotate-secret/`, { method: 'POST', } ); openModal(({Body, Header}) => (
{t('Your new Client Secret')}
{t('This will be the only time your client secret is visible!')} {rotateResponse.clientSecret}
)); } catch { addErrorMessage(t('Error rotating secret')); } }; const onFieldChange = (name: string, value: FieldValue): void => { if (name === 'webhookUrl' && !value && isInternal()) { // if no webhook, then set isAlertable to false form.setValue('isAlertable', false); } }; const addAvatar = ({avatar}: Model) => { if (app && avatar) { const avatars = app?.avatars?.filter(prevAvatar => prevAvatar.color !== avatar.color) || []; avatars.push(avatar as SentryAppAvatar); setApiQueryData(queryClient, SENTRY_APP_QUERY_KEY, {...app, avatars}); } }; const getAvatarModel = (isColor: boolean): Model => { const defaultModel: Model = { avatar: { avatarType: 'default', avatarUuid: null, }, }; if (!app) { return defaultModel; } return { avatar: app?.avatars?.find(({color}) => color === isColor) || defaultModel.avatar, }; }; const getAvatarPreview = (isColor: boolean) => { if (!app) { return null; } const avatarStyle = isColor ? 'color' : 'simple'; return ( {AVATAR_STYLES[avatarStyle].title} {AVATAR_STYLES[avatarStyle].previewText} ); }; const getAvatarChooser = (isColor: boolean) => { if (!app) { return null; } const avatarStyle = isColor ? 'color' : 'simple'; return ( ); }; const scopes = (app && [...app.scopes]) || []; const events = (app && normalize(app.events)) || []; const method = app ? 'PUT' : 'POST'; const endpoint = app ? `/sentry-apps/${app.slug}/` : '/sentry-apps/'; const forms = isInternal() ? internalIntegrationForms : publicIntegrationForms; let verifyInstall: boolean; if (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 (
{isEditingApp && isPending ? ( ) : isEditingApp && isError ? ( ) : (
{() => { const webhookDisabled = isInternal() && !form.getValue('webhookUrl'); return ( {getAvatarChooser(true)} {getAvatarChooser(false)} ); }} {app && app.status === 'internal' && ( {hasTokenAccess() ? ( {t('Tokens')} ) : ( {t('Tokens')} )} {renderTokens()} )} {app && ( {t('Credentials')} {app.status !== 'internal' && ( {({value, id}: any) => ( {getDynamicText({value, fixed: 'CI_CLIENT_ID'})} )} )} {({value, id}: any) => value ? ( {getDynamicText({value, fixed: 'CI_CLIENT_SECRET'})} ) : ( {t('hidden')} {hasTokenAccess() ? ( ) : undefined} ) } )}
)}
); } 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: ${p => p.theme.fontWeightBold}; `; const AvatarPreviewText = styled('span')` display: block; grid-area: 2 / 2 / 3 / 3; padding-left: ${space(2)}; `; const HiddenSecret = styled('span')` width: 100px; font-style: italic; `; const ClientSecret = styled('div')` display: flex; justify-content: right; align-items: center; margin-right: 0; `;