123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549 |
- 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 from 'sentry/components/panels/panel';
- import PanelBody from 'sentry/components/panels/panelBody';
- import PanelHeader from 'sentry/components/panels/panelHeader';
- import PanelItem from 'sentry/components/panels/panelItem';
- 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, 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 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: (
- <ExternalLink href="https://docs.sentry.io/product/integrations/integration-platform/ui-components/" />
- ),
- }),
- 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;
- tokens: InternalAppApiToken[];
- };
- class SentryApplicationDetails extends DeprecatedAsyncView<Props, State> {
- form = new SentryAppFormModel({mapFormErrors});
- getDefaultState(): State {
- return {
- ...super.getDefaultState(),
- app: null,
- tokens: [],
- };
- }
- getEndpoints(): ReturnType<DeprecatedAsyncView['getEndpoints']> {
- 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 && app.clientSecret && app.clientSecret[0] === '*');
- }
- onAddToken = async (evt: React.MouseEvent): Promise<void> => {
- 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 => (
- <StyledPanelItem key={token.token}>
- <TokenItem>
- <Tooltip
- disabled={this.showAuthInfo}
- position="right"
- containerDisplayMode="inline"
- title={t(
- 'You do not have access to view these credentials because the permissions for this integration exceed those of your role.'
- )}
- >
- <TextCopyInput aria-label={t('Token value')}>
- {getDynamicText({value: token.token, fixed: 'xxxxxx'})}
- </TextCopyInput>
- </Tooltip>
- </TokenItem>
- <CreatedDate>
- <CreatedTitle>Created:</CreatedTitle>
- <DateTime
- date={getDynamicText({
- value: token.dateCreated,
- fixed: new Date(1508208080000),
- })}
- />
- </CreatedDate>
- <Button
- onClick={this.onRemoveToken.bind(this, token)}
- size="sm"
- icon={<IconDelete />}
- data-test-id="token-delete"
- >
- {t('Revoke')}
- </Button>
- </StyledPanelItem>
- ));
- }
- return <EmptyMessage description={t('No tokens created yet.')} />;
- };
- 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 (
- <AvatarPreview>
- <StyledPreviewAvatar
- size={AVATAR_STYLES[avatarStyle].size}
- sentryApp={app}
- isDefault
- />
- <AvatarPreviewTitle>{AVATAR_STYLES[avatarStyle].title}</AvatarPreviewTitle>
- <AvatarPreviewText>{AVATAR_STYLES[avatarStyle].previewText}</AvatarPreviewText>
- </AvatarPreview>
- );
- };
- getAvatarChooser = (isColor: boolean) => {
- const {app} = this.state;
- if (!app) {
- return null;
- }
- const avatarStyle = isColor ? 'color' : 'simple';
- return (
- <AvatarChooser
- type={isColor ? 'sentryAppColor' : 'sentryAppSimple'}
- allowGravatar={false}
- allowLetter={false}
- endpoint={`/sentry-apps/${app.slug}/avatar/`}
- model={this.getAvatarModel(isColor)}
- onSave={this.addAvatar}
- title={isColor ? t('Logo') : t('Small Icon')}
- help={AVATAR_STYLES[avatarStyle].help.concat(
- this.isInternal ? '' : t(' Required for publishing.')
- )}
- savedDataUrl={undefined}
- defaultChoice={{
- allowDefault: true,
- choiceText: isColor ? t('Default logo') : t('Default small icon'),
- preview: this.getAvatarPreview(isColor),
- }}
- />
- );
- };
- 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 (
- <div>
- <SettingsPageHeader title={this.getHeaderTitle()} />
- <Form
- apiMethod={method}
- apiEndpoint={endpoint}
- allowUndo
- initialData={{
- organization: this.props.organization.slug,
- isAlertable: false,
- isInternal: this.isInternal,
- schema: {},
- scopes: [],
- ...app,
- verifyInstall, // need to overwrite the value in app for internal if it is true
- }}
- model={this.form}
- onSubmitSuccess={this.handleSubmitSuccess}
- onSubmitError={this.handleSubmitError}
- onFieldChange={this.onFieldChange}
- >
- <Observer>
- {() => {
- const webhookDisabled =
- this.isInternal && !this.form.getValue('webhookUrl');
- return (
- <Fragment>
- <JsonForm additionalFieldProps={{webhookDisabled}} forms={forms} />
- {this.getAvatarChooser(true)}
- {this.getAvatarChooser(false)}
- <PermissionsObserver
- webhookDisabled={webhookDisabled}
- appPublished={app ? app.status === 'published' : false}
- scopes={scopes}
- events={events}
- />
- </Fragment>
- );
- }}
- </Observer>
- {app && app.status === 'internal' && (
- <Panel>
- <PanelHeader hasButtons>
- {t('Tokens')}
- <Button
- size="xs"
- icon={<IconAdd isCircled />}
- onClick={evt => this.onAddToken(evt)}
- data-test-id="token-add"
- >
- {t('New Token')}
- </Button>
- </PanelHeader>
- <PanelBody>{this.renderTokens()}</PanelBody>
- </Panel>
- )}
- {app && (
- <Panel>
- <PanelHeader>{t('Credentials')}</PanelHeader>
- <PanelBody>
- {app.status !== 'internal' && (
- <FormField name="clientId" label="Client ID">
- {({value, id}) => (
- <TextCopyInput id={id}>
- {getDynamicText({value, fixed: 'CI_CLIENT_ID'})}
- </TextCopyInput>
- )}
- </FormField>
- )}
- <FormField name="clientSecret" label="Client Secret">
- {({value, id}) =>
- value ? (
- <Tooltip
- disabled={this.showAuthInfo}
- position="right"
- containerDisplayMode="inline"
- title={t(
- 'You do not have access to view these credentials because the permissions for this integration exceed those of your role.'
- )}
- >
- <TextCopyInput id={id}>
- {getDynamicText({value, fixed: 'CI_CLIENT_SECRET'})}
- </TextCopyInput>
- </Tooltip>
- ) : (
- <em>hidden</em>
- )
- }
- </FormField>
- </PanelBody>
- </Panel>
- )}
- </Form>
- </div>
- );
- }
- }
- export default withOrganization(SentryApplicationDetails);
- const StyledPanelItem = styled(PanelItem)`
- display: flex;
- align-items: center;
- 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)};
- `;
|