Skip to content

Commit e2caad6

Browse files
committed
Add new settings/tokens/new page
1 parent c85c801 commit e2caad6

File tree

8 files changed

+251
-4
lines changed

8 files changed

+251
-4
lines changed

app/components/header.hbs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727

2828
<dd.Menu local-class="current-user-links" as |menu|>
2929
<menu.Item><LinkTo @route="dashboard">Dashboard</LinkTo></menu.Item>
30-
<menu.Item><LinkTo @route="settings">Account Settings</LinkTo></menu.Item>
30+
<menu.Item><LinkTo @route="settings" data-test-settings>Account Settings</LinkTo></menu.Item>
3131
<menu.Item><LinkTo @route="me.pending-invites">Owner Invites</LinkTo></menu.Item>
3232
<menu.Item local-class="menu-item-with-separator">
3333
<button

app/components/settings-page.hbs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
<div local-class="page" ...attributes>
2-
<SideMenu as |menu|>
2+
<SideMenu data-test-settings-menu as |menu|>
33
<menu.Item @link={{link "settings.profile"}}>Profile</menu.Item>
44
{{#if this.design.showToggleButton}}
55
<menu.Item @link={{link "settings.appearance"}}>Appearance</menu.Item>
66
{{/if}}
77
<menu.Item @link={{link "settings.email-notifications"}}>Email Notifications</menu.Item>
8-
<menu.Item @link={{link "settings.tokens"}}>API Tokens</menu.Item>
8+
<menu.Item @link={{link "settings.tokens"}} data-test-tokens>API Tokens</menu.Item>
99
</SideMenu>
1010

1111
<div local-class="content">
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import Controller from '@ember/controller';
2+
import { action } from '@ember/object';
3+
import { inject as service } from '@ember/service';
4+
import { tracked } from '@glimmer/tracking';
5+
6+
import { task } from 'ember-concurrency';
7+
8+
export default class NewTokenController extends Controller {
9+
@service notifications;
10+
@service sentry;
11+
@service store;
12+
@service router;
13+
14+
@tracked name;
15+
@tracked nameInvalid;
16+
17+
constructor() {
18+
super(...arguments);
19+
this.reset();
20+
}
21+
22+
saveTokenTask = task(async () => {
23+
let { name } = this;
24+
if (!name) {
25+
this.nameInvalid = true;
26+
return;
27+
}
28+
29+
let token = this.store.createRecord('api-token', { name });
30+
31+
try {
32+
// Save the new API token on the backend
33+
await token.save();
34+
// Reset the form
35+
this.reset();
36+
// Navigate to the API token list
37+
this.router.transitionTo('settings.tokens.index');
38+
} catch (error) {
39+
// Notify the user
40+
this.notifications.error('An error has occurred while generating your API token. Please try again later!');
41+
// Notify the crates.io team
42+
this.sentry.captureException(error);
43+
// Notify the developer
44+
console.error(error);
45+
}
46+
});
47+
48+
reset() {
49+
this.name = '';
50+
this.nameInvalid = false;
51+
}
52+
53+
@action resetNameValidation() {
54+
this.nameInvalid = false;
55+
}
56+
}

app/router.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,9 @@ Router.map(function () {
3434
this.route('appearance');
3535
this.route('email-notifications');
3636
this.route('profile');
37-
this.route('tokens');
37+
this.route('tokens', function () {
38+
this.route('new');
39+
});
3840
});
3941
this.route('user', { path: '/users/:user_id' });
4042
this.route('install');

app/routes/settings/tokens/new.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import Route from '@ember/routing/route';
2+
3+
export default class TokenListRoute extends Route {
4+
resetController(controller) {
5+
controller.saveTokenTask.cancelAll();
6+
}
7+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
.form-group, .buttons {
2+
position: relative;
3+
margin: var(--space-s) 0;
4+
}
5+
6+
.form-group {
7+
label {
8+
display: block;
9+
margin-bottom: var(--space-3xs);
10+
font-weight: 600;
11+
}
12+
}
13+
14+
.buttons {
15+
display: flex;
16+
gap: var(--space-2xs);
17+
flex-wrap: wrap;
18+
}
19+
20+
.name-input {
21+
max-width: 440px;
22+
width: 100%;
23+
padding: var(--space-2xs);
24+
border: 1px solid #ada796;
25+
border-radius: var(--space-3xs);
26+
27+
&[aria-invalid="true"] {
28+
background: #fff2f2;
29+
border-color: red;
30+
}
31+
}
32+
33+
.generate-button {
34+
composes: yellow-button small from '../../../styles/shared/buttons.module.css';
35+
border-radius: 4px;
36+
37+
.spinner {
38+
margin-left: var(--space-2xs);
39+
}
40+
}
41+
42+
.cancel-button {
43+
composes: tan-button small from '../../../styles/shared/buttons.module.css';
44+
border-radius: 4px;
45+
}

app/templates/settings/tokens/new.hbs

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
<h2>New API Token</h2>
2+
3+
<form local-class="form" {{on "submit" (prevent-default (perform this.saveTokenTask))}}>
4+
<div local-class="form-group">
5+
<label for={{this.id}}>Name</label>
6+
<Input
7+
id={{this.id}}
8+
@type="text"
9+
@value={{this.name}}
10+
disabled={{this.saveTokenTask.isRunning}}
11+
aria-required="true"
12+
aria-invalid={{if this.nameInvalid "true" "false"}}
13+
local-class="name-input"
14+
data-test-name
15+
{{auto-focus}}
16+
{{on "input" this.resetNameValidation}}
17+
/>
18+
</div>
19+
20+
<div local-class="buttons">
21+
<button
22+
type="submit"
23+
local-class="generate-button"
24+
disabled={{this.saveTokenTask.isRunning}}
25+
data-test-generate
26+
>
27+
Generate Token
28+
29+
{{#if this.saveTokenTask.isRunning}}
30+
<LoadingSpinner local-class="spinner" data-test-spinner />
31+
{{/if}}
32+
</button>
33+
34+
<LinkTo
35+
@route="settings.tokens.index"
36+
local-class="cancel-button"
37+
data-test-cancel
38+
>
39+
Cancel
40+
</LinkTo>
41+
</div>
42+
43+
</form>
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import { click, currentURL, fillIn, waitFor } from '@ember/test-helpers';
2+
import { module, test } from 'qunit';
3+
4+
import { defer } from 'rsvp';
5+
6+
import { Response } from 'miragejs';
7+
8+
import { setupApplicationTest } from 'cargo/tests/helpers';
9+
10+
import { visit } from '../../../helpers/visit-ignoring-abort';
11+
12+
module('/settings/tokens/new', function (hooks) {
13+
setupApplicationTest(hooks);
14+
15+
function prepare(context) {
16+
let user = context.server.create('user', {
17+
login: 'johnnydee',
18+
name: 'John Doe',
19+
email: 'john@doe.com',
20+
avatar: 'https://avatars2.githubusercontent.com/u/1234567?v=4',
21+
});
22+
23+
context.authenticateAs(user);
24+
}
25+
26+
test('access is blocked if unauthenticated', async function (assert) {
27+
await visit('/settings/tokens/new');
28+
assert.strictEqual(currentURL(), '/settings/tokens/new');
29+
assert.dom('[data-test-title]').hasText('This page requires authentication');
30+
assert.dom('[data-test-login]').exists();
31+
});
32+
33+
test('happy path', async function (assert) {
34+
prepare(this);
35+
36+
await visit('/settings/tokens/new');
37+
assert.strictEqual(currentURL(), '/settings/tokens/new');
38+
39+
await fillIn('[data-test-name]', 'token-name');
40+
await click('[data-test-generate]');
41+
42+
let token = this.server.schema.apiTokens.findBy({ name: 'token-name' });
43+
assert.ok(Boolean(token), 'API token has been created in the backend database');
44+
45+
assert.strictEqual(currentURL(), '/settings/tokens');
46+
assert.dom('[data-test-api-token="1"] [data-test-name]').hasText('token-name');
47+
assert.dom('[data-test-api-token="1"] [data-test-token]').hasText(token.token);
48+
});
49+
50+
test('loading and error state', async function (assert) {
51+
prepare(this);
52+
53+
let deferred = defer();
54+
this.server.put('/api/v1/me/tokens', deferred.promise);
55+
56+
await visit('/settings/tokens/new');
57+
assert.strictEqual(currentURL(), '/settings/tokens/new');
58+
59+
await fillIn('[data-test-name]', 'token-name');
60+
let clickPromise = click('[data-test-generate]');
61+
await waitFor('[data-test-generate] [data-test-spinner]');
62+
assert.dom('[data-test-name]').isDisabled();
63+
assert.dom('[data-test-generate]').isDisabled();
64+
65+
deferred.resolve(new Response(500));
66+
await clickPromise;
67+
68+
let message = 'An error has occurred while generating your API token. Please try again later!';
69+
assert.dom('[data-test-notification-message="error"]').hasText(message);
70+
assert.dom('[data-test-name]').isEnabled();
71+
assert.dom('[data-test-generate]').isEnabled();
72+
});
73+
74+
test('cancel button navigates back to the token list', async function (assert) {
75+
prepare(this);
76+
77+
await visit('/settings/tokens/new');
78+
assert.strictEqual(currentURL(), '/settings/tokens/new');
79+
80+
await click('[data-test-cancel]');
81+
assert.strictEqual(currentURL(), '/settings/tokens');
82+
});
83+
84+
test('empty name shows an error', async function (assert) {
85+
prepare(this);
86+
87+
await visit('/settings/tokens/new');
88+
assert.strictEqual(currentURL(), '/settings/tokens/new');
89+
90+
await click('[data-test-generate]');
91+
assert.strictEqual(currentURL(), '/settings/tokens/new');
92+
assert.dom('[data-test-name]').hasAria('invalid', 'true');
93+
});
94+
});

0 commit comments

Comments
 (0)