Skip to content

Commit

Permalink
ref(token): Update create user token page to use dropdowns (#54651)
Browse files Browse the repository at this point in the history
Requires #56537

### Summary
Refactor the create user token page to use existing dropdowns (like
[org/integration tokens
page](https://santry.sentry.io/settings/developer-settings/new-internal/)).
This enforces hierarchy automatically when creating user tokens through
the UI.

### Scopes
Originally you could select scopes granularly, so you could check
`project:admin` but not `project:read`. The dropdowns will include all
weaker scopes for the same resource you're selecting. Ideally we would
call each option in the dropdown:
```mdx
Ideal          Current
- read         - read
- write   vs.  - read + write
- admin        - admin 
Note: Admin implies access to everything so includes read + write + any other special scope for that resource
e.g. project:releases/org:integrations
```
and explain the hierarchy in the header description with a link to our
scope docs. However we still need to overhaul our scope docs so this
change will have to come after the docs are complete.

### Before
<img width="1267" alt="Screenshot 2023-08-11 at 3 15 13 PM"
src="https://github.com/getsentry/sentry/assets/67301797/9e590eff-f839-4678-ab55-ac41610fdc8a">

### After


https://github.com/getsentry/sentry/assets/67301797/f0c1c730-ad51-4977-8b0c-0b026cf3d8b4
  • Loading branch information
schew2381 authored and michellewzhang committed Sep 21, 2023
1 parent 9591bbe commit 3d2111c
Show file tree
Hide file tree
Showing 3 changed files with 103 additions and 31 deletions.
16 changes: 4 additions & 12 deletions static/app/constants/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,17 +54,6 @@ export const API_ACCESS_SCOPES = [
'team:write',
] as const;

// Default API scopes when adding a new API token or org API token
export const DEFAULT_API_ACCESS_SCOPES = [
'event:admin',
'event:read',
'member:read',
'org:read',
'project:read',
'project:releases',
'team:read',
];

// These should only be used in the case where we cannot obtain roles through
// the members endpoint (primarily in cases where a user is admining a
// different organization they are not a OrganizationMember of ).
Expand Down Expand Up @@ -168,7 +157,10 @@ export const SENTRY_APP_PERMISSIONS: PermissionObj[] = [
'no-access': {label: 'No Access', scopes: []},
read: {label: 'Read', scopes: ['org:read']},
write: {label: 'Read & Write', scopes: ['org:read', 'org:write']},
admin: {label: 'Admin', scopes: ['org:read', 'org:write', 'org:admin']},
admin: {
label: 'Admin',
scopes: ['org:read', 'org:write', 'org:admin', 'org:integrations'],
},
},
},
{
Expand Down
67 changes: 66 additions & 1 deletion static/app/views/settings/account/apiNewToken.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import {render} from 'sentry-test/reactTestingLibrary';
import selectEvent from 'react-select-event';

import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary';

import ApiNewToken from 'sentry/views/settings/account/apiNewToken';

Expand All @@ -8,4 +10,67 @@ describe('ApiNewToken', function () {
context: TestStubs.routerContext(),
});
});

it('renders with disabled "Create Token" button', async function () {
render(<ApiNewToken />, {
context: TestStubs.routerContext(),
});

expect(await screen.getByRole('button', {name: 'Create Token'})).toBeDisabled();
});

it('submits with correct hierarchical scopes', async function () {
MockApiClient.clearMockResponses();
const assignMock = MockApiClient.addMockResponse({
method: 'POST',
url: `/api-tokens/`,
});

render(<ApiNewToken />, {
context: TestStubs.routerContext(),
});
const createButton = await screen.getByRole('button', {name: 'Create Token'});

const selectByValue = (name, value) =>
selectEvent.select(screen.getByRole('textbox', {name}), value);

// Assigning Admin here will also grant read + write access to the resource
await selectByValue('Project', 'Admin');
await selectByValue('Release', 'Admin');
await selectByValue('Team', 'Admin');
await selectByValue('Issue & Event', 'Admin');
await selectByValue('Organization', 'Admin');
await selectByValue('Member', 'Admin');

await userEvent.click(createButton);

await waitFor(() =>
expect(assignMock).toHaveBeenCalledWith(
'/api-tokens/',
expect.objectContaining({
data: expect.objectContaining({
scopes: expect.arrayContaining([
'project:read',
'project:write',
'project:admin',
'project:releases',
'team:read',
'team:write',
'team:admin',
'event:read',
'event:write',
'event:admin',
'org:read',
'org:write',
'org:admin',
'org:integrations',
'member:read',
'member:write',
'member:admin',
]),
}),
})
)
);
});
});
51 changes: 33 additions & 18 deletions static/app/views/settings/account/apiNewToken.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,38 @@ import {Component} from 'react';
import {browserHistory} from 'react-router';

import ApiForm from 'sentry/components/forms/apiForm';
import MultipleCheckbox from 'sentry/components/forms/controls/multipleCheckbox';
import FormField from 'sentry/components/forms/formField';
import ExternalLink from 'sentry/components/links/externalLink';
import Panel from 'sentry/components/panels/panel';
import PanelBody from 'sentry/components/panels/panelBody';
import PanelHeader from 'sentry/components/panels/panelHeader';
import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle';
import {API_ACCESS_SCOPES, DEFAULT_API_ACCESS_SCOPES} from 'sentry/constants';
import {t, tct} from 'sentry/locale';
import {Permissions} from 'sentry/types';
import {normalizeUrl} from 'sentry/utils/withDomainRequired';
import SettingsPageHeader from 'sentry/views/settings/components/settingsPageHeader';
import TextBlock from 'sentry/views/settings/components/text/textBlock';
import PermissionSelection from 'sentry/views/settings/organizationDeveloperSettings/permissionSelection';

const SORTED_DEFAULT_API_ACCESS_SCOPES = DEFAULT_API_ACCESS_SCOPES.sort();
const API_INDEX_ROUTE = '/settings/account/api/auth-tokens/';
type State = {
permissions: Permissions;
};

export default class ApiNewToken extends Component<{}, State> {
constructor(props: {}) {
super(props);
this.state = {
permissions: {
Event: 'no-access',
Team: 'no-access',
Member: 'no-access',
Project: 'no-access',
Release: 'no-access',
Organization: 'no-access',
},
};
}

export default class ApiNewToken extends Component {
onCancel = () => {
browserHistory.push(normalizeUrl(API_INDEX_ROUTE));
};
Expand All @@ -28,6 +43,7 @@ export default class ApiNewToken extends Component {
};

render() {
const {permissions} = this.state;
return (
<SentryDocumentTitle title={t('Create User Auth Token')}>
<div>
Expand All @@ -46,31 +62,30 @@ export default class ApiNewToken extends Component {
)}
</TextBlock>
<Panel>
<PanelHeader>{t('Create New User Auth Token')}</PanelHeader>
<PanelHeader>{t('Permissions')}</PanelHeader>
<ApiForm
apiMethod="POST"
apiEndpoint="/api-tokens/"
initialData={{scopes: SORTED_DEFAULT_API_ACCESS_SCOPES}}
initialData={{scopes: []}}
onSubmitSuccess={this.onSubmitSuccess}
onCancel={this.onCancel}
footerStyle={{
marginTop: 0,
paddingRight: 20,
}}
submitDisabled={Object.values(permissions).every(
value => value === 'no-access'
)}
submitLabel={t('Create Token')}
>
<PanelBody>
<FormField name="scopes" label={t('Scopes')} inline={false} required>
{({name, value, onChange}) => (
<MultipleCheckbox onChange={onChange} value={value} name={name}>
{API_ACCESS_SCOPES.map(scope => (
<MultipleCheckbox.Item value={scope} key={scope}>
{scope}
</MultipleCheckbox.Item>
))}
</MultipleCheckbox>
)}
</FormField>
<PermissionSelection
appPublished={false}
permissions={permissions}
onChange={value => {
this.setState({permissions: value});
}}
/>
</PanelBody>
</ApiForm>
</Panel>
Expand Down

0 comments on commit 3d2111c

Please sign in to comment.