permissionSelection.tsx 4.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164
  1. import {Component, Fragment} from 'react';
  2. import find from 'lodash/find';
  3. import flatMap from 'lodash/flatMap';
  4. import FormContext from 'sentry/components/forms/formContext';
  5. import SelectField from 'sentry/components/forms/selectField';
  6. import {SENTRY_APP_PERMISSIONS} from 'sentry/constants';
  7. import {t} from 'sentry/locale';
  8. import {PermissionResource, Permissions, PermissionValue} from 'sentry/types/index';
  9. /**
  10. * Custom form element that presents API scopes in a resource-centric way. Meaning
  11. * a dropdown for each resource, that rolls up into a flat list of scopes.
  12. *
  13. *
  14. * API Scope vs Permission
  15. *
  16. * "API Scopes" are the string identifier that gates an endpoint. For example,
  17. * `project:read` or `org:admin`. They're made up of two parts:
  18. *
  19. * <resource>:<access>
  20. *
  21. * "Permissions" are a more user-friendly way of conveying this same information.
  22. * They're roughly the same with one exception:
  23. *
  24. * - No Access
  25. * - Read
  26. * - Read & Write
  27. * - Admin
  28. *
  29. * "Read & Write" actually maps to the `write` access level since `read` is
  30. * implied. Similarly, `admin` implies `read` and `write`.
  31. *
  32. * This components displays things per Resource. Meaning the User selects
  33. * "Read", "Read & Write", or "Admin" for Project or Organization or etc.
  34. *
  35. * === Scopes to Permissions
  36. *
  37. * The first thing this component does on instantiation is take the list of API
  38. * Scopes passed via `props` and converts them to "Permissions.
  39. *
  40. * So a list of scopes like the following:
  41. *
  42. * ['project:read', 'project:write', 'org:admin']
  43. *
  44. * will become an object that looks like:
  45. *
  46. * {
  47. * 'Project': 'write',
  48. * 'Organization': 'admin',
  49. * }
  50. *
  51. *
  52. * State
  53. *
  54. * This component stores state like the example object from above. When the
  55. * User changes the Permission for a particular resource, it updates the
  56. * `state.permissions` object to reflect the change.
  57. *
  58. *
  59. * Updating the Form Field Value
  60. *
  61. * In addition to updating the state, when a value is changed this component
  62. * recalculates the full list of API Scopes that need to be passed to the API.
  63. *
  64. * So if the User has changed Project to "Admin" and Organization to "Read & Write",
  65. * we end up with a `state.permissions` like:
  66. *
  67. * {
  68. * 'Project': 'admin',
  69. * 'Organization': 'write',
  70. * }
  71. *
  72. * From there, we calculate the full list of API Scopes. This list includes all
  73. * implied scopes, meaning the above state would result in:
  74. *
  75. * ['project:read', 'project:write', 'project:admin', 'org:read', 'org:write']
  76. *
  77. */
  78. type Props = {
  79. appPublished: boolean;
  80. onChange: (permissions: Permissions) => void;
  81. permissions: Permissions;
  82. };
  83. type State = {
  84. permissions: Permissions;
  85. };
  86. function findResource(r: PermissionResource) {
  87. return find(SENTRY_APP_PERMISSIONS, ['resource', r]);
  88. }
  89. export default class PermissionSelection extends Component<Props, State> {
  90. state: State = {
  91. permissions: this.props.permissions,
  92. };
  93. static contextType = FormContext;
  94. /**
  95. * Converts the "Permission" values held in `state` to a list of raw
  96. * API scopes we can send to the server. For example:
  97. *
  98. * ['org:read', 'org:write', ...]
  99. *
  100. */
  101. permissionStateToList() {
  102. const {permissions} = this.state;
  103. return flatMap(
  104. Object.entries(permissions),
  105. ([r, p]) => findResource(r as PermissionResource)?.choices?.[p]?.scopes
  106. );
  107. }
  108. onChange = (resource: PermissionResource, choice: PermissionValue) => {
  109. const {permissions} = this.state;
  110. permissions[resource] = choice;
  111. this.save(permissions);
  112. };
  113. save = (permissions: Permissions) => {
  114. this.setState({permissions});
  115. this.props.onChange(permissions);
  116. this.context.form.setValue('scopes', this.permissionStateToList());
  117. };
  118. render() {
  119. const {permissions} = this.state;
  120. return (
  121. <Fragment>
  122. {SENTRY_APP_PERMISSIONS.map(config => {
  123. const options = Object.entries(config.choices).map(([value, {label}]) => ({
  124. value,
  125. label,
  126. }));
  127. const value = permissions[config.resource];
  128. return (
  129. <SelectField
  130. // These are not real fields we want submitted, so we use
  131. // `--permission` as a suffix here, then filter these
  132. // fields out when submitting the form in
  133. // sentryApplicationDetails.jsx
  134. name={`${config.resource}--permission`}
  135. key={config.resource}
  136. options={options}
  137. help={t(config.help)}
  138. label={t(config.label || config.resource)}
  139. onChange={this.onChange.bind(this, config.resource)}
  140. value={value}
  141. defaultValue={value}
  142. disabled={this.props.appPublished}
  143. disabledReason={t('Cannot update permissions on a published integration')}
  144. />
  145. );
  146. })}
  147. </Fragment>
  148. );
  149. }
  150. }