permissionSelection.tsx 4.9 KB

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