diff --git a/.changelog/15091.txt b/.changelog/15091.txt new file mode 100644 index 00000000000..0a51fb7a2ab --- /dev/null +++ b/.changelog/15091.txt @@ -0,0 +1,3 @@ +```release-note:improvement +ui: give users a notification if their token is going to expire within the next 10 minutes +``` diff --git a/ui/app/services/token.js b/ui/app/services/token.js index 514c9b371be..74393162ff0 100644 --- a/ui/app/services/token.js +++ b/ui/app/services/token.js @@ -3,16 +3,20 @@ import { computed } from '@ember/object'; import { alias, reads } from '@ember/object/computed'; import { getOwner } from '@ember/application'; import { assign } from '@ember/polyfills'; -import { task } from 'ember-concurrency'; +import { task, timeout } from 'ember-concurrency'; import queryString from 'query-string'; import fetch from 'nomad-ui/utils/fetch'; import classic from 'ember-classic-decorator'; +import moment from 'moment'; +const MINUTES_LEFT_AT_WARNING = 10; +const EXPIRY_NOTIFICATION_TITLE = 'Your access is about to expire'; @classic export default class TokenService extends Service { @service store; @service system; @service router; + @service flashMessages; aclEnabled = true; @@ -77,6 +81,7 @@ export default class TokenService extends Service { @task(function* () { yield this.fetchSelfToken.perform(); + this.kickoffTokenTTLMonitoring(); if (this.aclEnabled) { yield this.fetchSelfTokenPolicies.perform(); } @@ -115,7 +120,67 @@ export default class TokenService extends Service { this.fetchSelfToken.cancelAll({ resetState: true }); this.fetchSelfTokenPolicies.cancelAll({ resetState: true }); this.fetchSelfTokenAndPolicies.cancelAll({ resetState: true }); + this.monitorTokenTime.cancelAll({ resetState: true }); } + + kickoffTokenTTLMonitoring() { + this.monitorTokenTime.perform(); + } + + @task(function* () { + while (this.selfToken?.expirationTime) { + const diff = new Date(this.selfToken.expirationTime) - new Date(); + // Let the user know at the 10 minute mark, + // or any time they refresh with under 10 minutes left + if (diff < 1000 * 60 * MINUTES_LEFT_AT_WARNING) { + const existingNotification = this.flashMessages.queue?.find( + (m) => m.title === EXPIRY_NOTIFICATION_TITLE + ); + // For the sake of updating the "time left" message, we keep running the task down to the moment of expiration + if (diff > 0) { + if (existingNotification) { + existingNotification.set( + 'message', + `Your token access expires ${moment( + this.selfToken.expirationTime + ).fromNow()}` + ); + } else { + if (!this.expirationNotificationDismissed) { + this.flashMessages.add({ + title: EXPIRY_NOTIFICATION_TITLE, + message: `Your token access expires ${moment( + this.selfToken.expirationTime + ).fromNow()}`, + type: 'error', + destroyOnClick: false, + sticky: true, + customCloseAction: () => { + this.set('expirationNotificationDismissed', true); + }, + customAction: { + label: 'Re-authenticate', + action: () => { + this.router.transitionTo('settings.tokens'); + }, + }, + }); + } + } + } else { + if (existingNotification) { + existingNotification.setProperties({ + title: 'Your access has expired', + message: `Your token will need to be re-authenticated`, + }); + } + this.monitorTokenTime.cancelAll(); // Stop updating time left after expiration + } + } + yield timeout(1000); + } + }) + monitorTokenTime; } function addParams(url, params) { diff --git a/ui/app/styles/core/notifications.scss b/ui/app/styles/core/notifications.scss index 90ff3066266..d73a8258b52 100644 --- a/ui/app/styles/core/notifications.scss +++ b/ui/app/styles/core/notifications.scss @@ -1,3 +1,5 @@ +$bonusRightPadding: 20px; + section.notifications { position: fixed; bottom: 10px; @@ -11,7 +13,7 @@ section.notifications { box-shadow: 1px 1px 4px 0px rgb(0, 0, 0, 0.1); position: relative; overflow: hidden; - padding-right: 20px; + padding-right: $bonusRightPadding; &.alert-success { background-color: lighten($nomad-green, 50%); @@ -54,5 +56,10 @@ section.notifications { } } } + + .custom-action-button { + width: calc(100% + $bonusRightPadding - 1rem); + margin: 1.5rem 0 0; + } } } diff --git a/ui/app/templates/application.hbs b/ui/app/templates/application.hbs index 8a16c4f2db9..edf8c1c3f90 100644 --- a/ui/app/templates/application.hbs +++ b/ui/app/templates/application.hbs @@ -8,13 +8,21 @@
{{#each this.flashMessages.queue as |flash|}} - × + × {{#if flash.title}}

{{flash.title}}

{{/if}} {{#if flash.message}}

{{flash.message}}

{{/if}} + {{#if flash.customAction}} + + {{/if}} {{#if component.showProgressBar}}
diff --git a/ui/app/templates/settings/tokens.hbs b/ui/app/templates/settings/tokens.hbs index 416fb1133c4..48def8ccfea 100644 --- a/ui/app/templates/settings/tokens.hbs +++ b/ui/app/templates/settings/tokens.hbs @@ -41,48 +41,49 @@
- {{/if}} - {{#unless this.tokenIsValid}} -
- -
- + {{#unless this.tokenIsValid}} +
+ +
+ +
+

Sent with every request to determine authorization

-

Sent with every request to determine authorization

-
-

- {{/unless}} +

+ {{/unless}} - {{#if this.tokenIsValid}} -
-
-
-

Token Authenticated!

-

Your token is valid and authorized for the following policies.

+ {{#if this.tokenIsValid}} +
+
+
+

Token Authenticated!

+

Your token is valid and authorized for the following policies.

+
-
- {{/if}} + {{/if}} - {{#if this.tokenIsInvalid}} -
-
-
-

Token Failed to Authenticate

-

The token secret you have provided does not match an existing token.

+ {{#if this.tokenIsInvalid}} +
+
+
+

Token Failed to Authenticate

+

The token secret you have provided does not match an existing token, or has expired.

+
-
+ {{/if}} + {{/if}} {{#if this.tokenRecord}} diff --git a/ui/mirage/factories/token.js b/ui/mirage/factories/token.js index 575463c9bab..12961a3bb62 100644 --- a/ui/mirage/factories/token.js +++ b/ui/mirage/factories/token.js @@ -164,5 +164,10 @@ node { server.create('policy', variableViewerPolicy); token.policyIds.push(variableViewerPolicy.id); } + if (token.id === '3XP1R35-1N-3L3V3N-M1NU735') { + token.update({ + expirationTime: new Date(new Date().getTime() + 11 * 60 * 1000), + }); + } }, }); diff --git a/ui/mirage/scenarios/default.js b/ui/mirage/scenarios/default.js index a9725376995..529231009e4 100644 --- a/ui/mirage/scenarios/default.js +++ b/ui/mirage/scenarios/default.js @@ -473,6 +473,10 @@ function createTokens(server) { name: "Safe O'Constants", id: 'f3w3r-53cur3-v4r14bl35', }); + server.create('token', { + name: 'Lazarus MacMarbh', + id: '3XP1R35-1N-3L3V3N-M1NU735', + }); logTokens(server); } diff --git a/ui/tests/acceptance/token-test.js b/ui/tests/acceptance/token-test.js index c64fb3b4367..32649e71730 100644 --- a/ui/tests/acceptance/token-test.js +++ b/ui/tests/acceptance/token-test.js @@ -1,5 +1,5 @@ /* eslint-disable qunit/require-expect */ -import { currentURL, find, visit } from '@ember/test-helpers'; +import { currentURL, find, visit, click } from '@ember/test-helpers'; import { module, skip, test } from 'qunit'; import { setupApplicationTest } from 'ember-qunit'; import { setupMirage } from 'ember-cli-mirage/test-support'; @@ -12,6 +12,7 @@ import Layout from 'nomad-ui/tests/pages/layout'; import percySnapshot from '@percy/ember'; import faker from 'nomad-ui/mirage/faker'; import moment from 'moment'; +import { run } from '@ember/runloop'; let job; let node; @@ -183,17 +184,13 @@ module('Acceptance | tokens', function (hooks) { }); test('it handles expiring tokens', async function (assert) { + // Soon-expiring token const expiringToken = server.create('token', { name: "Time's a-tickin", expirationTime: moment().add(1, 'm').toDate(), }); - // Soon-expiring token await Tokens.visit(); - await Tokens.secret(expiringToken.secretId).submit(); - assert - .dom('[data-test-token-expiry]') - .exists('Expiry shown for TTL-having token'); // Token with no TTL await Tokens.clear(); @@ -201,6 +198,30 @@ module('Acceptance | tokens', function (hooks) { assert .dom('[data-test-token-expiry]') .doesNotExist('No expiry shown for regular token'); + + await Tokens.clear(); + + // https://ember-concurrency.com/docs/testing-debugging/ + setTimeout(() => run.cancelTimers(), 500); + + // Token with TTL + await Tokens.secret(expiringToken.secretId).submit(); + assert + .dom('[data-test-token-expiry]') + .exists('Expiry shown for TTL-having token'); + + // TTL Action + await Jobs.visit(); + assert + .dom('.flash-message.alert-error button') + .exists('A global alert exists and has a clickable button'); + + await click('.flash-message.alert-error button'); + assert.equal( + currentURL(), + '/settings/tokens', + 'Redirected to tokens page on notification action' + ); }); test('it handles expired tokens', async function (assert) { @@ -270,6 +291,43 @@ module('Acceptance | tokens', function (hooks) { ); }); + test('it notifies you when your token has 10 minutes remaining', async function (assert) { + let notificationRendered = assert.async(); + let notificationNotRendered = assert.async(); + window.localStorage.clear(); + assert.equal( + window.localStorage.nomadTokenSecret, + null, + 'No token secret set' + ); + assert.timeout(6000); + const nearlyExpiringToken = server.create('token', { + name: 'Not quite dead yet', + expirationTime: moment().add(10, 'm').add(5, 's').toDate(), + }); + + await Tokens.visit(); + await Tokens.clear(); + // Ember Concurrency makes testing iterations convoluted: https://ember-concurrency.com/docs/testing-debugging/ + // Waiting for half a second to validate that there's no warning; + // then a further 5 seconds to validate that there is a warning, and to explicitly cancelAllTimers(), + // short-circuiting our Ember Concurrency loop. + setTimeout(() => { + assert + .dom('.flash-message.alert-error') + .doesNotExist('No notification yet for a token with 10m5s left'); + notificationNotRendered(); + setTimeout(() => { + assert + .dom('.flash-message.alert-error') + .exists('Notification is rendered at the 10m mark'); + notificationRendered(); + run.cancelTimers(); + }, 5000); + }, 500); + await Tokens.secret(nearlyExpiringToken.secretId).submit(); + }); + test('when the ott query parameter is present upon application load it’s exchanged for a token', async function (assert) { const { oneTimeSecret, secretId } = managementToken;