sentryApplicationDetails.tsx 16 KB

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