diff --git a/app/components/settings/api-tokens.js b/app/components/settings/api-tokens.js
index c8911efaf49..0aa28aefdca 100644
--- a/app/components/settings/api-tokens.js
+++ b/app/components/settings/api-tokens.js
@@ -8,6 +8,7 @@ import { task } from 'ember-concurrency';
export default class ApiTokens extends Component {
@service store;
@service notifications;
+ @service router;
@tracked newToken;
@@ -15,8 +16,12 @@ export default class ApiTokens extends Component {
return this.args.tokens.filter(t => !t.isNew).sort((a, b) => (a.created_at < b.created_at ? 1 : -1));
}
- @action startNewToken() {
- this.newToken = this.store.createRecord('api-token');
+ @action startNewToken(event) {
+ if (event.altKey) {
+ this.router.transitionTo('settings.tokens.new');
+ } else {
+ this.newToken = this.store.createRecord('api-token');
+ }
}
saveTokenTask = task(async () => {
diff --git a/app/components/side-menu/item.hbs b/app/components/side-menu/item.hbs
index fc5ad8644be..9190eac58aa 100644
--- a/app/components/side-menu/item.hbs
+++ b/app/components/side-menu/item.hbs
@@ -1,3 +1,3 @@
-
+
{{yield}}
\ No newline at end of file
diff --git a/app/controllers/settings/tokens/new.js b/app/controllers/settings/tokens/new.js
new file mode 100644
index 00000000000..e7c829b0250
--- /dev/null
+++ b/app/controllers/settings/tokens/new.js
@@ -0,0 +1,56 @@
+import Controller from '@ember/controller';
+import { action } from '@ember/object';
+import { inject as service } from '@ember/service';
+import { tracked } from '@glimmer/tracking';
+
+import { task } from 'ember-concurrency';
+
+export default class NewTokenController extends Controller {
+ @service notifications;
+ @service sentry;
+ @service store;
+ @service router;
+
+ @tracked name;
+ @tracked nameInvalid;
+
+ constructor() {
+ super(...arguments);
+ this.reset();
+ }
+
+ saveTokenTask = task(async () => {
+ let { name } = this;
+ if (!name) {
+ this.nameInvalid = true;
+ return;
+ }
+
+ let token = this.store.createRecord('api-token', { name });
+
+ try {
+ // Save the new API token on the backend
+ await token.save();
+ // Reset the form
+ this.reset();
+ // Navigate to the API token list
+ this.router.transitionTo('settings.tokens.index');
+ } catch (error) {
+ // Notify the user
+ this.notifications.error('An error has occurred while generating your API token. Please try again later!');
+ // Notify the crates.io team
+ this.sentry.captureException(error);
+ // Notify the developer
+ console.error(error);
+ }
+ });
+
+ reset() {
+ this.name = '';
+ this.nameInvalid = false;
+ }
+
+ @action resetNameValidation() {
+ this.nameInvalid = false;
+ }
+}
diff --git a/app/router.js b/app/router.js
index 8936900e89b..f5d5fa814ca 100644
--- a/app/router.js
+++ b/app/router.js
@@ -34,7 +34,9 @@ Router.map(function () {
this.route('appearance');
this.route('email-notifications');
this.route('profile');
- this.route('tokens');
+ this.route('tokens', function () {
+ this.route('new');
+ });
});
this.route('user', { path: '/users/:user_id' });
this.route('install');
diff --git a/app/routes/settings/tokens.js b/app/routes/settings/tokens.js
index 2691bdcceb9..5c446d472b6 100644
--- a/app/routes/settings/tokens.js
+++ b/app/routes/settings/tokens.js
@@ -1,26 +1,3 @@
-import { inject as service } from '@ember/service';
-
-import { TrackedArray } from 'tracked-built-ins';
-
import AuthenticatedRoute from '../-authenticated-route';
-export default class TokenSettingsRoute extends AuthenticatedRoute {
- @service store;
-
- async model() {
- let apiTokens = await this.store.findAll('api-token');
- return TrackedArray.from(apiTokens.slice());
- }
-
- /**
- * Ensure that all plaintext tokens are deleted from memory after leaving
- * the API tokens settings page.
- */
- resetController(controller) {
- for (let token of controller.model) {
- if (token.token) {
- token.token = undefined;
- }
- }
- }
-}
+export default class TokenSettingsRoute extends AuthenticatedRoute {}
diff --git a/app/routes/settings/tokens/index.js b/app/routes/settings/tokens/index.js
new file mode 100644
index 00000000000..5e2ac363156
--- /dev/null
+++ b/app/routes/settings/tokens/index.js
@@ -0,0 +1,25 @@
+import Route from '@ember/routing/route';
+import { inject as service } from '@ember/service';
+
+import { TrackedArray } from 'tracked-built-ins';
+
+export default class TokenListRoute extends Route {
+ @service store;
+
+ async model() {
+ let apiTokens = await this.store.findAll('api-token');
+ return TrackedArray.from(apiTokens.slice());
+ }
+
+ /**
+ * Ensure that all plaintext tokens are deleted from memory after leaving
+ * the API tokens settings page.
+ */
+ resetController(controller) {
+ for (let token of controller.model) {
+ if (token.token) {
+ token.token = undefined;
+ }
+ }
+ }
+}
diff --git a/app/routes/settings/tokens/new.js b/app/routes/settings/tokens/new.js
new file mode 100644
index 00000000000..179ad057ee9
--- /dev/null
+++ b/app/routes/settings/tokens/new.js
@@ -0,0 +1,7 @@
+import Route from '@ember/routing/route';
+
+export default class TokenListRoute extends Route {
+ resetController(controller) {
+ controller.saveTokenTask.cancelAll();
+ }
+}
diff --git a/app/styles/settings/tokens/new.module.css b/app/styles/settings/tokens/new.module.css
new file mode 100644
index 00000000000..052d9ec9ae9
--- /dev/null
+++ b/app/styles/settings/tokens/new.module.css
@@ -0,0 +1,45 @@
+.form-group, .buttons {
+ position: relative;
+ margin: var(--space-s) 0;
+}
+
+.form-group {
+ label {
+ display: block;
+ margin-bottom: var(--space-3xs);
+ font-weight: 600;
+ }
+}
+
+.buttons {
+ display: flex;
+ gap: var(--space-2xs);
+ flex-wrap: wrap;
+}
+
+.name-input {
+ max-width: 440px;
+ width: 100%;
+ padding: var(--space-2xs);
+ border: 1px solid #ada796;
+ border-radius: var(--space-3xs);
+
+ &[aria-invalid="true"] {
+ background: #fff2f2;
+ border-color: red;
+ }
+}
+
+.generate-button {
+ composes: yellow-button small from '../../../styles/shared/buttons.module.css';
+ border-radius: 4px;
+
+ .spinner {
+ margin-left: var(--space-2xs);
+ }
+}
+
+.cancel-button {
+ composes: tan-button small from '../../../styles/shared/buttons.module.css';
+ border-radius: 4px;
+}
diff --git a/app/styles/shared/buttons.module.css b/app/styles/shared/buttons.module.css
index 8157106dde7..536eb92d7a0 100644
--- a/app/styles/shared/buttons.module.css
+++ b/app/styles/shared/buttons.module.css
@@ -33,6 +33,10 @@
background: linear-gradient(to bottom, var(--bg-color-top) 0%, var(--bg-color-bottom) 100%);
cursor: pointer;
+ &:hover, &:active, &:visited {
+ color: var(--text-color);
+ }
+
img, svg {
float: left;
display: inline-block;
diff --git a/app/templates/settings/tokens.hbs b/app/templates/settings/tokens.hbs
index c0e85fe6b1c..ef5c317c0d8 100644
--- a/app/templates/settings/tokens.hbs
+++ b/app/templates/settings/tokens.hbs
@@ -3,5 +3,5 @@
-
+ {{outlet}}
\ No newline at end of file
diff --git a/app/templates/settings/tokens/index.hbs b/app/templates/settings/tokens/index.hbs
new file mode 100644
index 00000000000..cb741b2fec3
--- /dev/null
+++ b/app/templates/settings/tokens/index.hbs
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/app/templates/settings/tokens/new.hbs b/app/templates/settings/tokens/new.hbs
new file mode 100644
index 00000000000..36369fac120
--- /dev/null
+++ b/app/templates/settings/tokens/new.hbs
@@ -0,0 +1,43 @@
+
New API Token
+
+
diff --git a/tests/routes/settings/tokens/new-test.js b/tests/routes/settings/tokens/new-test.js
new file mode 100644
index 00000000000..0e7ffa7f296
--- /dev/null
+++ b/tests/routes/settings/tokens/new-test.js
@@ -0,0 +1,111 @@
+import { click, currentURL, fillIn, waitFor } from '@ember/test-helpers';
+import { module, test } from 'qunit';
+
+import { defer } from 'rsvp';
+
+import { Response } from 'miragejs';
+
+import { setupApplicationTest } from 'cargo/tests/helpers';
+
+import { visit } from '../../../helpers/visit-ignoring-abort';
+
+module('/settings/tokens/new', function (hooks) {
+ setupApplicationTest(hooks);
+
+ function prepare(context) {
+ let user = context.server.create('user', {
+ login: 'johnnydee',
+ name: 'John Doe',
+ email: 'john@doe.com',
+ avatar: 'https://avatars2.githubusercontent.com/u/1234567?v=4',
+ });
+
+ context.authenticateAs(user);
+ }
+
+ test('can navigate to the route', async function (assert) {
+ prepare(this);
+
+ await visit('/');
+ assert.strictEqual(currentURL(), '/');
+
+ await click('[data-test-user-menu] [data-test-toggle]');
+ await click('[data-test-user-menu] [data-test-settings]');
+ assert.strictEqual(currentURL(), '/settings/profile');
+
+ await click('[data-test-settings-menu] [data-test-tokens] a');
+ assert.strictEqual(currentURL(), '/settings/tokens');
+
+ await click('[data-test-new-token-button]', { altKey: true });
+ assert.strictEqual(currentURL(), '/settings/tokens/new');
+ });
+
+ test('access is blocked if unauthenticated', async function (assert) {
+ await visit('/settings/tokens/new');
+ assert.strictEqual(currentURL(), '/settings/tokens/new');
+ assert.dom('[data-test-title]').hasText('This page requires authentication');
+ assert.dom('[data-test-login]').exists();
+ });
+
+ test('happy path', 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]');
+
+ 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(currentURL(), '/settings/tokens');
+ assert.dom('[data-test-api-token="1"] [data-test-name]').hasText('token-name');
+ assert.dom('[data-test-api-token="1"] [data-test-token]').hasText(token.token);
+ });
+
+ test('loading and error state', async function (assert) {
+ prepare(this);
+
+ let deferred = defer();
+ this.server.put('/api/v1/me/tokens', deferred.promise);
+
+ await visit('/settings/tokens/new');
+ assert.strictEqual(currentURL(), '/settings/tokens/new');
+
+ await fillIn('[data-test-name]', 'token-name');
+ let clickPromise = click('[data-test-generate]');
+ await waitFor('[data-test-generate] [data-test-spinner]');
+ assert.dom('[data-test-name]').isDisabled();
+ assert.dom('[data-test-generate]').isDisabled();
+
+ deferred.resolve(new Response(500));
+ await clickPromise;
+
+ let message = 'An error has occurred while generating your API token. Please try again later!';
+ assert.dom('[data-test-notification-message="error"]').hasText(message);
+ assert.dom('[data-test-name]').isEnabled();
+ assert.dom('[data-test-generate]').isEnabled();
+ });
+
+ test('cancel button navigates back to the token list', async function (assert) {
+ prepare(this);
+
+ await visit('/settings/tokens/new');
+ assert.strictEqual(currentURL(), '/settings/tokens/new');
+
+ await click('[data-test-cancel]');
+ assert.strictEqual(currentURL(), '/settings/tokens');
+ });
+
+ test('empty name shows an error', async function (assert) {
+ prepare(this);
+
+ await visit('/settings/tokens/new');
+ assert.strictEqual(currentURL(), '/settings/tokens/new');
+
+ await click('[data-test-generate]');
+ assert.strictEqual(currentURL(), '/settings/tokens/new');
+ assert.dom('[data-test-name]').hasAria('invalid', 'true');
+ });
+});