permissionSelection.tsx 5.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164
  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 type {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. declare context: Required<React.ContextType<typeof FormContext>>;
  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(
  114. 'scopes',
  115. permissionStateToList(this.state.permissions) as string[]
  116. );
  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={config.help}
  138. label={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. }