permissionSelection.tsx 5.0 KB

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