sentryApplicationDetails.tsx 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546
  1. import {Fragment} from 'react';
  2. import {browserHistory, RouteComponentProps} from 'react-router';
  3. import styled from '@emotion/styled';
  4. import omit from 'lodash/omit';
  5. import {Observer} from 'mobx-react';
  6. import scrollToElement from 'scroll-to-element';
  7. import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator';
  8. import {
  9. addSentryAppToken,
  10. removeSentryAppToken,
  11. } from 'sentry/actionCreators/sentryAppTokens';
  12. import Avatar from 'sentry/components/avatar';
  13. import AvatarChooser, {Model} from 'sentry/components/avatarChooser';
  14. import {Button} from 'sentry/components/button';
  15. import DateTime from 'sentry/components/dateTime';
  16. import EmptyMessage from 'sentry/components/emptyMessage';
  17. import Form from 'sentry/components/forms/form';
  18. import FormField from 'sentry/components/forms/formField';
  19. import JsonForm from 'sentry/components/forms/jsonForm';
  20. import FormModel, {FieldValue} from 'sentry/components/forms/model';
  21. import ExternalLink from 'sentry/components/links/externalLink';
  22. import {Panel, PanelBody, PanelHeader, PanelItem} from 'sentry/components/panels';
  23. import TextCopyInput from 'sentry/components/textCopyInput';
  24. import {Tooltip} from 'sentry/components/tooltip';
  25. import {SENTRY_APP_PERMISSIONS} from 'sentry/constants';
  26. import {
  27. internalIntegrationForms,
  28. publicIntegrationForms,
  29. } from 'sentry/data/forms/sentryApplication';
  30. import {IconAdd, IconDelete} from 'sentry/icons';
  31. import {t, tct} from 'sentry/locale';
  32. import {space} from 'sentry/styles/space';
  33. import {InternalAppApiToken, Organization, Scope, SentryApp} from 'sentry/types';
  34. import getDynamicText from 'sentry/utils/getDynamicText';
  35. import {normalizeUrl} from 'sentry/utils/withDomainRequired';
  36. import withOrganization from 'sentry/utils/withOrganization';
  37. import AsyncView from 'sentry/views/asyncView';
  38. import SettingsPageHeader from 'sentry/views/settings/components/settingsPageHeader';
  39. import PermissionsObserver from 'sentry/views/settings/organizationDeveloperSettings/permissionsObserver';
  40. type Resource = 'Project' | 'Team' | 'Release' | 'Event' | 'Organization' | 'Member';
  41. const AVATAR_STYLES = {
  42. color: {
  43. size: 50,
  44. title: t('Default Logo'),
  45. previewText: t('The default icon for integrations'),
  46. help: t('Image must be between 256px by 256px and 1024px by 1024px.'),
  47. },
  48. simple: {
  49. size: 20,
  50. title: t('Default Icon'),
  51. previewText: tct('This is a silhouette icon used only for [uiDocs:UI Components]', {
  52. uiDocs: (
  53. <ExternalLink href="https://docs.sentry.io/product/integrations/integration-platform/ui-components/" />
  54. ),
  55. }),
  56. help: t(
  57. 'Image must be between 256px by 256px and 1024px by 1024px, and may only use black and transparent pixels.'
  58. ),
  59. },
  60. };
  61. /**
  62. * Finds the resource in SENTRY_APP_PERMISSIONS that contains a given scope
  63. * We should always find a match unless there is a bug
  64. * @param {Scope} scope
  65. * @return {Resource | undefined}
  66. */
  67. const getResourceFromScope = (scope: Scope): Resource | undefined => {
  68. for (const permObj of SENTRY_APP_PERMISSIONS) {
  69. const allChoices = Object.values(permObj.choices);
  70. const allScopes = allChoices.reduce(
  71. (_allScopes: string[], choice) => _allScopes.concat(choice?.scopes ?? []),
  72. []
  73. );
  74. if (allScopes.includes(scope)) {
  75. return permObj.resource as Resource;
  76. }
  77. }
  78. return undefined;
  79. };
  80. class SentryAppFormModel extends FormModel {
  81. /**
  82. * Filter out Permission input field values.
  83. *
  84. * Permissions (API Scopes) are presented as a list of SelectFields.
  85. * Instead of them being submitted individually, we want them rolled
  86. * up into a single list of scopes (this is done in `PermissionSelection`).
  87. *
  88. * Because they are all individual inputs, we end up with attributes
  89. * in the JSON we send to the API that we don't want.
  90. *
  91. * This function filters those attributes out of the data that is
  92. * ultimately sent to the API.
  93. */
  94. getData() {
  95. return this.fields.toJSON().reduce((data, [k, v]) => {
  96. if (!k.endsWith('--permission')) {
  97. data[k] = v;
  98. }
  99. return data;
  100. }, {});
  101. }
  102. /**
  103. * We need to map the API response errors to the actual form fields.
  104. * We do this by pulling out scopes and mapping each scope error to the correct input.
  105. * @param {Object} responseJSON
  106. */
  107. mapFormErrors(responseJSON?: any) {
  108. if (!responseJSON) {
  109. return responseJSON;
  110. }
  111. const formErrors = omit(responseJSON, ['scopes']);
  112. if (responseJSON.scopes) {
  113. responseJSON.scopes.forEach((message: string) => {
  114. // find the scope from the error message of a specific format
  115. const matches = message.match(/Requested permission of (\w+:\w+)/);
  116. if (matches) {
  117. const scope = matches[1];
  118. const resource = getResourceFromScope(scope as Scope);
  119. // should always match but technically resource can be undefined
  120. if (resource) {
  121. formErrors[`${resource}--permission`] = [message];
  122. }
  123. }
  124. });
  125. }
  126. return formErrors;
  127. }
  128. }
  129. type Props = RouteComponentProps<{appSlug?: string}, {}> & {
  130. organization: Organization;
  131. };
  132. type State = AsyncView['state'] & {
  133. app: SentryApp | null;
  134. tokens: InternalAppApiToken[];
  135. };
  136. class SentryApplicationDetails extends AsyncView<Props, State> {
  137. form = new SentryAppFormModel();
  138. getDefaultState(): State {
  139. return {
  140. ...super.getDefaultState(),
  141. app: null,
  142. tokens: [],
  143. };
  144. }
  145. getEndpoints(): ReturnType<AsyncView['getEndpoints']> {
  146. const {appSlug} = this.props.params;
  147. if (appSlug) {
  148. return [
  149. ['app', `/sentry-apps/${appSlug}/`],
  150. ['tokens', `/sentry-apps/${appSlug}/api-tokens/`],
  151. ];
  152. }
  153. return [];
  154. }
  155. getHeaderTitle() {
  156. const {app} = this.state;
  157. const action = app ? 'Edit' : 'Create';
  158. const type = this.isInternal ? 'Internal' : 'Public';
  159. return tct('[action] [type] Integration', {action, type});
  160. }
  161. // Events may come from the API as "issue.created" when we just want "issue" here.
  162. normalize(events) {
  163. if (events.length === 0) {
  164. return events;
  165. }
  166. return events.map(e => e.split('.').shift());
  167. }
  168. handleSubmitSuccess = (data: SentryApp) => {
  169. const {app} = this.state;
  170. const {organization} = this.props;
  171. const type = this.isInternal ? 'internal' : 'public';
  172. const baseUrl = `/settings/${organization.slug}/developer-settings/`;
  173. const url = app ? `${baseUrl}?type=${type}` : `${baseUrl}${data.slug}/`;
  174. if (app) {
  175. addSuccessMessage(t('%s successfully saved.', data.name));
  176. } else {
  177. addSuccessMessage(t('%s successfully created.', data.name));
  178. }
  179. browserHistory.push(normalizeUrl(url));
  180. };
  181. handleSubmitError = err => {
  182. let errorMessage = t('Unknown Error');
  183. if (err.status >= 400 && err.status < 500) {
  184. errorMessage = err?.responseJSON.detail ?? errorMessage;
  185. }
  186. addErrorMessage(errorMessage);
  187. if (this.form.formErrors) {
  188. const firstErrorFieldId = Object.keys(this.form.formErrors)[0];
  189. if (firstErrorFieldId) {
  190. scrollToElement(`#${firstErrorFieldId}`, {
  191. align: 'middle',
  192. offset: 0,
  193. });
  194. }
  195. }
  196. };
  197. get isInternal() {
  198. const {app} = this.state;
  199. if (app) {
  200. // if we are editing an existing app, check the status of the app
  201. return app.status === 'internal';
  202. }
  203. return this.props.route.path === 'new-internal/';
  204. }
  205. get showAuthInfo() {
  206. const {app} = this.state;
  207. return !(app && app.clientSecret && app.clientSecret[0] === '*');
  208. }
  209. onAddToken = async (evt: React.MouseEvent): Promise<void> => {
  210. evt.preventDefault();
  211. const {app, tokens} = this.state;
  212. if (!app) {
  213. return;
  214. }
  215. const api = this.api;
  216. const token = await addSentryAppToken(api, app);
  217. const newTokens = tokens.concat(token);
  218. this.setState({tokens: newTokens});
  219. };
  220. onRemoveToken = async (token: InternalAppApiToken, evt: React.MouseEvent) => {
  221. evt.preventDefault();
  222. const {app, tokens} = this.state;
  223. if (!app) {
  224. return;
  225. }
  226. const api = this.api;
  227. const newTokens = tokens.filter(tok => tok.token !== token.token);
  228. await removeSentryAppToken(api, app, token.token);
  229. this.setState({tokens: newTokens});
  230. };
  231. renderTokens = () => {
  232. const {tokens} = this.state;
  233. if (tokens.length > 0) {
  234. return tokens.map(token => (
  235. <StyledPanelItem key={token.token}>
  236. <TokenItem>
  237. <Tooltip
  238. disabled={this.showAuthInfo}
  239. position="right"
  240. containerDisplayMode="inline"
  241. title={t(
  242. 'You do not have access to view these credentials because the permissions for this integration exceed those of your role.'
  243. )}
  244. >
  245. <TextCopyInput aria-label={t('Token value')}>
  246. {getDynamicText({value: token.token, fixed: 'xxxxxx'})}
  247. </TextCopyInput>
  248. </Tooltip>
  249. </TokenItem>
  250. <CreatedDate>
  251. <CreatedTitle>Created:</CreatedTitle>
  252. <DateTime
  253. date={getDynamicText({
  254. value: token.dateCreated,
  255. fixed: new Date(1508208080000),
  256. })}
  257. />
  258. </CreatedDate>
  259. <Button
  260. onClick={this.onRemoveToken.bind(this, token)}
  261. size="sm"
  262. icon={<IconDelete />}
  263. data-test-id="token-delete"
  264. >
  265. {t('Revoke')}
  266. </Button>
  267. </StyledPanelItem>
  268. ));
  269. }
  270. return <EmptyMessage description={t('No tokens created yet.')} />;
  271. };
  272. onFieldChange = (name: string, value: FieldValue): void => {
  273. if (name === 'webhookUrl' && !value && this.isInternal) {
  274. // if no webhook, then set isAlertable to false
  275. this.form.setValue('isAlertable', false);
  276. }
  277. };
  278. addAvatar = ({avatar}: Model) => {
  279. const {app} = this.state;
  280. if (app && avatar) {
  281. const avatars =
  282. app?.avatars?.filter(prevAvatar => prevAvatar.color !== avatar.color) || [];
  283. avatars.push(avatar);
  284. this.setState({app: {...app, avatars}});
  285. }
  286. };
  287. getAvatarModel = (isColor: boolean): Model => {
  288. const {app} = this.state;
  289. const defaultModel: Model = {
  290. avatar: {
  291. avatarType: 'default',
  292. avatarUuid: null,
  293. },
  294. };
  295. if (!app) {
  296. return defaultModel;
  297. }
  298. return {
  299. avatar: app?.avatars?.find(({color}) => color === isColor) || defaultModel.avatar,
  300. };
  301. };
  302. getAvatarPreview = (isColor: boolean) => {
  303. const {app} = this.state;
  304. if (!app) {
  305. return null;
  306. }
  307. const avatarStyle = isColor ? 'color' : 'simple';
  308. return (
  309. <AvatarPreview>
  310. <StyledPreviewAvatar
  311. size={AVATAR_STYLES[avatarStyle].size}
  312. sentryApp={app}
  313. isDefault
  314. />
  315. <AvatarPreviewTitle>{AVATAR_STYLES[avatarStyle].title}</AvatarPreviewTitle>
  316. <AvatarPreviewText>{AVATAR_STYLES[avatarStyle].previewText}</AvatarPreviewText>
  317. </AvatarPreview>
  318. );
  319. };
  320. getAvatarChooser = (isColor: boolean) => {
  321. const {app} = this.state;
  322. if (!app) {
  323. return null;
  324. }
  325. const avatarStyle = isColor ? 'color' : 'simple';
  326. return (
  327. <AvatarChooser
  328. type={isColor ? 'sentryAppColor' : 'sentryAppSimple'}
  329. allowGravatar={false}
  330. allowLetter={false}
  331. endpoint={`/sentry-apps/${app.slug}/avatar/`}
  332. model={this.getAvatarModel(isColor)}
  333. onSave={this.addAvatar}
  334. title={isColor ? t('Logo') : t('Small Icon')}
  335. help={AVATAR_STYLES[avatarStyle].help.concat(
  336. this.isInternal ? '' : t(' Required for publishing.')
  337. )}
  338. savedDataUrl={undefined}
  339. defaultChoice={{
  340. allowDefault: true,
  341. choiceText: isColor ? t('Default logo') : t('Default small icon'),
  342. preview: this.getAvatarPreview(isColor),
  343. }}
  344. />
  345. );
  346. };
  347. renderBody() {
  348. const {app} = this.state;
  349. const scopes = (app && [...app.scopes]) || [];
  350. const events = (app && this.normalize(app.events)) || [];
  351. const method = app ? 'PUT' : 'POST';
  352. const endpoint = app ? `/sentry-apps/${app.slug}/` : '/sentry-apps/';
  353. const forms = this.isInternal ? internalIntegrationForms : publicIntegrationForms;
  354. let verifyInstall: boolean;
  355. if (this.isInternal) {
  356. // force verifyInstall to false for all internal apps
  357. verifyInstall = false;
  358. } else {
  359. // use the existing value for verifyInstall if the app exists, otherwise default to true
  360. verifyInstall = app ? app.verifyInstall : true;
  361. }
  362. return (
  363. <div>
  364. <SettingsPageHeader title={this.getHeaderTitle()} />
  365. <Form
  366. apiMethod={method}
  367. apiEndpoint={endpoint}
  368. allowUndo
  369. initialData={{
  370. organization: this.props.organization.slug,
  371. isAlertable: false,
  372. isInternal: this.isInternal,
  373. schema: {},
  374. scopes: [],
  375. ...app,
  376. verifyInstall, // need to overwrite the value in app for internal if it is true
  377. }}
  378. model={this.form}
  379. onSubmitSuccess={this.handleSubmitSuccess}
  380. onSubmitError={this.handleSubmitError}
  381. onFieldChange={this.onFieldChange}
  382. >
  383. <Observer>
  384. {() => {
  385. const webhookDisabled =
  386. this.isInternal && !this.form.getValue('webhookUrl');
  387. return (
  388. <Fragment>
  389. <JsonForm additionalFieldProps={{webhookDisabled}} forms={forms} />
  390. {this.getAvatarChooser(true)}
  391. {this.getAvatarChooser(false)}
  392. <PermissionsObserver
  393. webhookDisabled={webhookDisabled}
  394. appPublished={app ? app.status === 'published' : false}
  395. scopes={scopes}
  396. events={events}
  397. />
  398. </Fragment>
  399. );
  400. }}
  401. </Observer>
  402. {app && app.status === 'internal' && (
  403. <Panel>
  404. <PanelHeader hasButtons>
  405. {t('Tokens')}
  406. <Button
  407. size="xs"
  408. icon={<IconAdd size="xs" isCircled />}
  409. onClick={evt => this.onAddToken(evt)}
  410. data-test-id="token-add"
  411. >
  412. {t('New Token')}
  413. </Button>
  414. </PanelHeader>
  415. <PanelBody>{this.renderTokens()}</PanelBody>
  416. </Panel>
  417. )}
  418. {app && (
  419. <Panel>
  420. <PanelHeader>{t('Credentials')}</PanelHeader>
  421. <PanelBody>
  422. {app.status !== 'internal' && (
  423. <FormField name="clientId" label="Client ID">
  424. {({value, id}) => (
  425. <TextCopyInput id={id}>
  426. {getDynamicText({value, fixed: 'CI_CLIENT_ID'})}
  427. </TextCopyInput>
  428. )}
  429. </FormField>
  430. )}
  431. <FormField name="clientSecret" label="Client Secret">
  432. {({value, id}) =>
  433. value ? (
  434. <Tooltip
  435. disabled={this.showAuthInfo}
  436. position="right"
  437. containerDisplayMode="inline"
  438. title={t(
  439. 'You do not have access to view these credentials because the permissions for this integration exceed those of your role.'
  440. )}
  441. >
  442. <TextCopyInput id={id}>
  443. {getDynamicText({value, fixed: 'CI_CLIENT_SECRET'})}
  444. </TextCopyInput>
  445. </Tooltip>
  446. ) : (
  447. <em>hidden</em>
  448. )
  449. }
  450. </FormField>
  451. </PanelBody>
  452. </Panel>
  453. )}
  454. </Form>
  455. </div>
  456. );
  457. }
  458. }
  459. export default withOrganization(SentryApplicationDetails);
  460. const StyledPanelItem = styled(PanelItem)`
  461. display: flex;
  462. align-items: center;
  463. justify-content: space-between;
  464. `;
  465. const TokenItem = styled('div')`
  466. width: 70%;
  467. `;
  468. const CreatedTitle = styled('span')`
  469. color: ${p => p.theme.gray300};
  470. margin-bottom: 2px;
  471. `;
  472. const CreatedDate = styled('div')`
  473. display: flex;
  474. flex-direction: column;
  475. font-size: 14px;
  476. margin: 0 10px;
  477. `;
  478. const AvatarPreview = styled('div')`
  479. flex: 1;
  480. display: grid;
  481. grid: 25px 25px / 50px 1fr;
  482. `;
  483. const StyledPreviewAvatar = styled(Avatar)`
  484. grid-area: 1 / 1 / 3 / 2;
  485. justify-self: end;
  486. `;
  487. const AvatarPreviewTitle = styled('span')`
  488. display: block;
  489. grid-area: 1 / 2 / 2 / 3;
  490. padding-left: ${space(2)};
  491. font-weight: bold;
  492. `;
  493. const AvatarPreviewText = styled('span')`
  494. display: block;
  495. grid-area: 2 / 2 / 3 / 3;
  496. padding-left: ${space(2)};
  497. `;