diff --git a/app/controllers/settings/tokens/new.js b/app/controllers/settings/tokens/new.js
index 4a9f54046b5..336a66914cf 100644
--- a/app/controllers/settings/tokens/new.js
+++ b/app/controllers/settings/tokens/new.js
@@ -16,6 +16,9 @@ export default class NewTokenController extends Controller {
@tracked name;
@tracked nameInvalid;
+ @tracked expirySelection;
+ @tracked expiryDateInput;
+ @tracked expiryDateInvalid;
@tracked scopes;
@tracked scopesInvalid;
@tracked crateScopes;
@@ -29,6 +32,31 @@ export default class NewTokenController extends Controller {
this.reset();
}
+ get today() {
+ return new Date().toISOString().slice(0, 10);
+ }
+
+ get expiryDate() {
+ if (this.expirySelection === 'none') return null;
+ if (this.expirySelection === 'custom') {
+ if (!this.expiryDateInput) return null;
+
+ let now = new Date();
+ let timeSuffix = now.toISOString().slice(10);
+ return new Date(this.expiryDateInput + timeSuffix);
+ }
+
+ let date = new Date();
+ date.setDate(date.getDate() + Number(this.expirySelection));
+ return date;
+ }
+
+ get expiryDescription() {
+ return this.expirySelection === 'none'
+ ? 'The token will never expire'
+ : `The token will expire on ${this.expiryDate.toLocaleDateString(undefined, { dateStyle: 'long' })}`;
+ }
+
@action isScopeSelected(id) {
return this.scopes.includes(id);
}
@@ -46,6 +74,7 @@ export default class NewTokenController extends Controller {
name,
endpoint_scopes: scopes,
crate_scopes: crateScopes,
+ expired_at: this.expiryDate,
});
try {
@@ -68,6 +97,9 @@ export default class NewTokenController extends Controller {
reset() {
this.name = '';
this.nameInvalid = false;
+ this.expirySelection = 'none';
+ this.expiryDateInput = null;
+ this.expiryDateInvalid = false;
this.scopes = [];
this.scopesInvalid = false;
this.crateScopes = TrackedArray.of();
@@ -75,16 +107,26 @@ export default class NewTokenController extends Controller {
validate() {
this.nameInvalid = !this.name;
+ this.expiryDateInvalid = this.expirySelection === 'custom' && !this.expiryDateInput;
this.scopesInvalid = this.scopes.length === 0;
let crateScopesValid = this.crateScopes.map(pattern => pattern.validate(false)).every(Boolean);
- return !this.nameInvalid && !this.scopesInvalid && crateScopesValid;
+ return !this.nameInvalid && !this.expiryDateInvalid && !this.scopesInvalid && crateScopesValid;
}
@action resetNameValidation() {
this.nameInvalid = false;
}
+ @action updateExpirySelection(event) {
+ this.expiryDateInput = this.expiryDate?.toISOString().slice(0, 10);
+ this.expirySelection = event.target.value;
+ }
+
+ @action resetExpiryDateValidation() {
+ this.expiryDateInvalid = false;
+ }
+
@action toggleScope(id) {
this.scopes = this.scopes.includes(id) ? this.scopes.filter(it => it !== id) : [...this.scopes, id];
this.scopesInvalid = false;
diff --git a/app/styles/application.module.css b/app/styles/application.module.css
index 22a29f26806..6fbd3ec022b 100644
--- a/app/styles/application.module.css
+++ b/app/styles/application.module.css
@@ -142,6 +142,22 @@ noscript {
color: white;
}
+/* see https://github.com/twbs/bootstrap/pull/30269 */
+::-webkit-datetime-edit,
+::-webkit-datetime-edit-fields-wrapper,
+::-webkit-datetime-edit-text,
+::-webkit-datetime-edit-minute,
+::-webkit-datetime-edit-hour-field,
+::-webkit-datetime-edit-day-field,
+::-webkit-datetime-edit-month-field,
+::-webkit-datetime-edit-year-field {
+ padding: 0;
+}
+
+::-webkit-calendar-picker-indicator {
+ font-size: 0.9em
+}
+
:global {
.c-notification__icon {
display: flex;
diff --git a/app/styles/settings/tokens/new.module.css b/app/styles/settings/tokens/new.module.css
index b31e6dd83b5..70ba09b5c7b 100644
--- a/app/styles/settings/tokens/new.module.css
+++ b/app/styles/settings/tokens/new.module.css
@@ -43,10 +43,9 @@
flex-wrap: wrap;
}
-.name-input {
- max-width: 440px;
- width: 100%;
+.base-input {
padding: var(--space-2xs);
+ background-color: white;
border: 1px solid var(--gray-border);
border-radius: var(--space-3xs);
@@ -56,6 +55,33 @@
}
}
+.name-input {
+ composes: base-input;
+
+ max-width: 440px;
+ width: 100%;
+}
+
+.expiry-select {
+ composes: base-input;
+
+ padding-right: var(--space-m);
+ background-image: url("data:image/svg+xml,");
+ background-repeat: no-repeat;
+ background-position: calc(100% - var(--space-2xs)) center;
+ background-size: 10px;
+ appearance: none;
+}
+
+.expiry-date-input {
+ composes: base-input;
+}
+
+.expiry-description {
+ margin-left: var(--space-2xs);
+ font-size: 0.9em;
+}
+
.scopes-list {
list-style: none;
padding: 0;
diff --git a/app/templates/settings/tokens/new.hbs b/app/templates/settings/tokens/new.hbs
index a2bde9c2713..6b64a42f0d1 100644
--- a/app/templates/settings/tokens/new.hbs
+++ b/app/templates/settings/tokens/new.hbs
@@ -26,6 +26,46 @@
{{/let}}
+
+ {{#let (unique-id) as |id|}}
+
+
+
+ {{/let}}
+
+ {{#if (eq this.expirySelection "custom")}}
+
+ {{else}}
+
+ {{this.expiryDescription}}
+
+ {{/if}}
+
+
Scopes
diff --git a/tests/routes/settings/tokens/new-test.js b/tests/routes/settings/tokens/new-test.js
index 176d573c4fc..ea7910aae94 100644
--- a/tests/routes/settings/tokens/new-test.js
+++ b/tests/routes/settings/tokens/new-test.js
@@ -1,4 +1,4 @@
-import { click, currentURL, fillIn, waitFor } from '@ember/test-helpers';
+import { click, currentURL, fillIn, select, waitFor } from '@ember/test-helpers';
import { module, test } from 'qunit';
import { defer } from 'rsvp';
@@ -60,6 +60,7 @@ module('/settings/tokens/new', function (hooks) {
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.expiredAt, null);
assert.strictEqual(token.crateScopes, null);
assert.deepEqual(token.endpointScopes, ['publish-update']);
@@ -68,6 +69,7 @@ module('/settings/tokens/new', function (hooks) {
assert.dom('[data-test-api-token="1"] [data-test-token]').hasText(token.token);
assert.dom('[data-test-api-token="1"] [data-test-endpoint-scopes]').hasText('Scopes: publish-update');
assert.dom('[data-test-api-token="1"] [data-test-crate-scopes]').doesNotExist();
+ assert.dom('[data-test-api-token="1"] [data-test-expired-at]').doesNotExist();
});
test('crate scopes', async function (assert) {
@@ -138,6 +140,75 @@ module('/settings/tokens/new', function (hooks) {
assert.dom('[data-test-api-token="1"] [data-test-token]').hasText(token.token);
assert.dom('[data-test-api-token="1"] [data-test-endpoint-scopes]').hasText('Scopes: publish-update and yank');
assert.dom('[data-test-api-token="1"] [data-test-crate-scopes]').hasText('Crates: serde-* and serde');
+ assert.dom('[data-test-api-token="1"] [data-test-expired-at]').doesNotExist();
+ });
+
+ test('token expiry', async function (assert) {
+ prepare(this);
+
+ await visit('/settings/tokens/new');
+ assert.strictEqual(currentURL(), '/settings/tokens/new');
+ assert.dom('[data-test-expiry-description]').hasText('The token will never expire');
+
+ await fillIn('[data-test-name]', 'token-name');
+ await select('[data-test-expiry]', '30');
+
+ let expiryDate = new Date('2017-12-20');
+ let expectedDate = expiryDate.toLocaleDateString(undefined, { dateStyle: 'long' });
+ let expectedDescription = `The token will expire on ${expectedDate}`;
+ assert.dom('[data-test-expiry-description]').hasText(expectedDescription);
+
+ 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.expiredAt.slice(0, 10), '2017-12-20');
+ 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');
+ assert.dom('[data-test-api-token="1"] [data-test-token]').hasText(token.token);
+ assert.dom('[data-test-api-token="1"] [data-test-endpoint-scopes]').hasText('Scopes: publish-update');
+ assert.dom('[data-test-api-token="1"] [data-test-crate-scopes]').doesNotExist();
+ assert.dom('[data-test-api-token="1"] [data-test-expired-at]').hasText('Expires in about 1 month');
+ });
+
+ test('token expiry with custom date', async function (assert) {
+ prepare(this);
+
+ await visit('/settings/tokens/new');
+ assert.strictEqual(currentURL(), '/settings/tokens/new');
+ assert.dom('[data-test-expiry-description]').hasText('The token will never expire');
+
+ await fillIn('[data-test-name]', 'token-name');
+ await select('[data-test-expiry]', 'custom');
+ assert.dom('[data-test-expiry-description]').doesNotExist();
+
+ await click('[data-test-scope="publish-update"]');
+ await click('[data-test-generate]');
+ assert.dom('[data-test-expiry-date]').hasAria('invalid', 'true');
+
+ await fillIn('[data-test-expiry-date]', '2024-05-04');
+ assert.dom('[data-test-expiry-description]').doesNotExist();
+
+ 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.expiredAt.slice(0, 10), '2024-05-04');
+ 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');
+ assert.dom('[data-test-api-token="1"] [data-test-token]').hasText(token.token);
+ assert.dom('[data-test-api-token="1"] [data-test-endpoint-scopes]').hasText('Scopes: publish-update');
+ assert.dom('[data-test-api-token="1"] [data-test-crate-scopes]').doesNotExist();
+ assert.dom('[data-test-api-token="1"] [data-test-expired-at]').hasText('Expires in over 6 years');
});
test('loading and error state', async function (assert) {