Skip to content

settings/tokens/new: Add "Scopes" section #6428

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
May 5, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 30 additions & 6 deletions app/controllers/settings/tokens/new.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,20 +13,30 @@ export default class NewTokenController extends Controller {

@tracked name;
@tracked nameInvalid;
@tracked scopes;
@tracked scopesInvalid;

ENDPOINT_SCOPES = [
{ id: 'change-owners', description: 'Invite new crate owners or remove existing ones' },
{ id: 'publish-new', description: 'Publish new crates' },
{ id: 'publish-update', description: 'Publish new versions of existing crates' },
{ id: 'yank', description: 'Yank and unyank crate versions' },
];

constructor() {
super(...arguments);
this.reset();
}

@action isScopeSelected(id) {
return this.scopes.includes(id);
}

saveTokenTask = task(async () => {
let { name } = this;
if (!name) {
this.nameInvalid = true;
return;
}
if (!this.validate()) return;
let { name, scopes } = this;

let token = this.store.createRecord('api-token', { name });
let token = this.store.createRecord('api-token', { name, endpoint_scopes: scopes });

try {
// Save the new API token on the backend
Expand All @@ -48,9 +58,23 @@ export default class NewTokenController extends Controller {
reset() {
this.name = '';
this.nameInvalid = false;
this.scopes = [];
this.scopesInvalid = false;
}

validate() {
this.nameInvalid = !this.name;
this.scopesInvalid = this.scopes.length === 0;

return !this.nameInvalid && !this.scopesInvalid;
}

@action resetNameValidation() {
this.nameInvalid = false;
}

@action toggleScope(id) {
this.scopes = this.scopes.includes(id) ? this.scopes.filter(it => it !== id) : [...this.scopes, id];
this.scopesInvalid = false;
}
}
4 changes: 4 additions & 0 deletions app/models/api-token.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,8 @@ export default class ApiToken extends Model {
@attr token;
@attr('date') created_at;
@attr('date') last_used_at;
/** @type string[] | null */
@attr crate_scopes;
/** @type string[] | null */
@attr endpoint_scopes;
}
58 changes: 50 additions & 8 deletions app/styles/settings/tokens/new.module.css
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
.form-group, .buttons {
position: relative;
margin: var(--space-s) 0;
margin: var(--space-m) 0;
}

.form-group {
label {
display: block;
margin-bottom: var(--space-3xs);
font-weight: 600;
}
.form-group-name {
display: block;
margin-bottom: var(--space-2xs);
font-weight: 600;
}

.form-group-error {
display: block;
color: red;
font-size: 0.9em;
margin-top: var(--space-2xs);
}

.buttons {
Expand All @@ -21,7 +26,7 @@
max-width: 440px;
width: 100%;
padding: var(--space-2xs);
border: 1px solid #ada796;
border: 1px solid var(--gray-border);
border-radius: var(--space-3xs);

&[aria-invalid="true"] {
Expand All @@ -30,6 +35,43 @@
}
}

.scopes-list {
list-style: none;
padding: 0;
margin: 0;
background-color: white;
border: 1px solid var(--gray-border);
border-radius: var(--space-3xs);

&.invalid {
background: #fff2f2;
border-color: red;
}

> * + * {
border-top: inherit;
}

label {
padding: var(--space-xs) var(--space-s);
display: flex;
flex-wrap: wrap;
gap: var(--space-xs);
font-size: 0.9em;
}
}

.scope-id {
display: inline-block;
max-width: 170px;
flex-grow: 1;
font-weight: bold;
}

.scope-description {
display: inline-block;
}

.generate-button {
composes: yellow-button small from '../../../styles/shared/buttons.module.css';
border-radius: 4px;
Expand Down
39 changes: 37 additions & 2 deletions app/templates/settings/tokens/new.hbs
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
<h2>New API Token</h2>

<form local-class="form" {{on "submit" (prevent-default (perform this.saveTokenTask))}}>
<div local-class="form-group">
<div local-class="form-group" data-test-name-group>
{{#let (unique-id) as |id|}}
<label for={{id}}>Name</label>
<label for={{id}} local-class="form-group-name">Name</label>

<Input
id={{id}}
@type="text"
Expand All @@ -16,9 +17,43 @@
{{auto-focus}}
{{on "input" this.resetNameValidation}}
/>

{{#if this.nameInvalid}}
<div local-class="form-group-error" data-test-error>
Please enter a name for this token.
</div>
{{/if}}
{{/let}}
</div>

<div local-class="form-group" data-test-scopes-group>
<div local-class="form-group-name">Scopes</div>

<ul role="list" local-class="scopes-list {{if this.scopesInvalid "invalid"}}">
{{#each this.ENDPOINT_SCOPES as |scope|}}
<li>
<label data-test-scope={{scope.id}}>
<Input
@type="checkbox"
@checked={{this.isScopeSelected scope.id}}
disabled={{this.saveTokenTask.isRunning}}
{{on "change" (fn this.toggleScope scope.id)}}
/>

<span local-class="scope-id">{{scope.id}}</span>
<span local-class="scope-description">{{scope.description}}</span>
</label>
</li>
{{/each}}
</ul>

{{#if this.scopesInvalid}}
<div local-class="form-group-error" data-test-error>
Please select at least one token scope.
</div>
{{/if}}
</div>

<div local-class="buttons">
<button
type="submit"
Expand Down
21 changes: 21 additions & 0 deletions tests/routes/settings/tokens/new-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,10 +54,14 @@ module('/settings/tokens/new', function (hooks) {
assert.strictEqual(currentURL(), '/settings/tokens/new');

await fillIn('[data-test-name]', 'token-name');
await click('[data-test-scope="publish-update"]');
await click('[data-test-generate]');

let token = this.server.schema.apiTokens.findBy({ name: 'token-name' });
assert.ok(Boolean(token), 'API token has been created in the backend database');
assert.strictEqual(token.name, 'token-name');
assert.strictEqual(token.crateScopes, null);
assert.deepEqual(token.endpointScopes, ['publish-update']);

assert.strictEqual(currentURL(), '/settings/tokens');
assert.dom('[data-test-api-token="1"] [data-test-name]').hasText('token-name');
Expand All @@ -74,6 +78,7 @@ module('/settings/tokens/new', function (hooks) {
assert.strictEqual(currentURL(), '/settings/tokens/new');

await fillIn('[data-test-name]', 'token-name');
await click('[data-test-scope="publish-update"]');
let clickPromise = click('[data-test-generate]');
await waitFor('[data-test-generate] [data-test-spinner]');
assert.dom('[data-test-name]').isDisabled();
Expand Down Expand Up @@ -104,8 +109,24 @@ module('/settings/tokens/new', function (hooks) {
await visit('/settings/tokens/new');
assert.strictEqual(currentURL(), '/settings/tokens/new');

await click('[data-test-scope="publish-update"]');
await click('[data-test-generate]');
assert.strictEqual(currentURL(), '/settings/tokens/new');
assert.dom('[data-test-name]').hasAria('invalid', 'true');
assert.dom('[data-test-name-group] [data-test-error]').exists();
assert.dom('[data-test-scopes-group] [data-test-error]').doesNotExist();
});

test('no scopes selected shows an error', async function (assert) {
prepare(this);

await visit('/settings/tokens/new');
assert.strictEqual(currentURL(), '/settings/tokens/new');

await fillIn('[data-test-name]', 'token-name');
await click('[data-test-generate]');
assert.strictEqual(currentURL(), '/settings/tokens/new');
assert.dom('[data-test-name-group] [data-test-error]').doesNotExist();
assert.dom('[data-test-scopes-group] [data-test-error]').exists();
});
});