permissionSelection.tsx 4.9 KB

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