permissionSelection.tsx 4.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163
  1. import {Component, Fragment} from 'react';
  2. import find from 'lodash/find';
  3. import flatMap from 'lodash/flatMap';
  4. import SelectField from 'sentry/components/forms/fields/selectField';
  5. import FormContext from 'sentry/components/forms/formContext';
  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. /**
  90. * Converts the "Permission" values held in `state` to a list of raw
  91. * API scopes we can send to the server. For example:
  92. *
  93. * ['org:read', 'org:write', ...]
  94. *
  95. */
  96. function permissionStateToList(permissions: Permissions) {
  97. return flatMap(
  98. Object.entries(permissions),
  99. ([r, p]) => findResource(r as PermissionResource)?.choices?.[p]?.scopes
  100. );
  101. }
  102. export default class PermissionSelection extends Component<Props, State> {
  103. state: State = {
  104. permissions: this.props.permissions,
  105. };
  106. static contextType = FormContext;
  107. onChange = (resource: PermissionResource, choice: PermissionValue) => {
  108. const {permissions} = this.state;
  109. permissions[resource] = choice;
  110. this.save(permissions);
  111. };
  112. save = (permissions: Permissions) => {
  113. this.setState({permissions});
  114. this.props.onChange(permissions);
  115. this.context.form.setValue('scopes', permissionStateToList(this.state.permissions));
  116. };
  117. render() {
  118. const {permissions} = this.state;
  119. return (
  120. <Fragment>
  121. {SENTRY_APP_PERMISSIONS.map(config => {
  122. const options = Object.entries(config.choices).map(([value, {label}]) => ({
  123. value,
  124. label,
  125. }));
  126. const value = permissions[config.resource];
  127. return (
  128. <SelectField
  129. // These are not real fields we want submitted, so we use
  130. // `--permission` as a suffix here, then filter these
  131. // fields out when submitting the form in
  132. // sentryApplicationDetails.jsx
  133. name={`${config.resource}--permission`}
  134. key={config.resource}
  135. options={options}
  136. help={config.help}
  137. label={config.label || config.resource}
  138. onChange={this.onChange.bind(this, config.resource)}
  139. value={value}
  140. defaultValue={value}
  141. disabled={this.props.appPublished}
  142. disabledReason={t('Cannot update permissions on a published integration')}
  143. />
  144. );
  145. })}
  146. </Fragment>
  147. );
  148. }
  149. }