diff --git a/.changelog/15073.txt b/.changelog/15073.txt new file mode 100644 index 00000000000..bed479b4605 --- /dev/null +++ b/.changelog/15073.txt @@ -0,0 +1,3 @@ +```release-note:improvement +ui: redirect users to Sign In should their tokens ever come back expired or not-found +``` diff --git a/ui/app/controllers/settings/tokens.js b/ui/app/controllers/settings/tokens.js index 7fe6df52fac..6a63e5a8dd8 100644 --- a/ui/app/controllers/settings/tokens.js +++ b/ui/app/controllers/settings/tokens.js @@ -25,6 +25,7 @@ export default class Tokens extends Controller { clearTokenProperties() { this.token.setProperties({ secret: undefined, + tokenNotFound: false, }); this.setProperties({ tokenIsValid: false, @@ -54,6 +55,7 @@ export default class Tokens extends Controller { tokenIsValid: true, tokenIsInvalid: false, }); + this.token.set('tokenNotFound', false); }, () => { this.set('token.secret', undefined); diff --git a/ui/app/models/token.js b/ui/app/models/token.js index 17937cc1ff5..47faeeabbb6 100644 --- a/ui/app/models/token.js +++ b/ui/app/models/token.js @@ -11,6 +11,11 @@ export default class Token extends Model { @attr('string') type; @hasMany('policy') policies; @attr() policyNames; + @attr('date') expirationTime; @alias('id') accessor; + + get isExpired() { + return this.expirationTime && this.expirationTime < new Date(); + } } diff --git a/ui/app/routes/application.js b/ui/app/routes/application.js index be815bd44c8..c4d60aec1b7 100644 --- a/ui/app/routes/application.js +++ b/ui/app/routes/application.js @@ -13,6 +13,7 @@ export default class ApplicationRoute extends Route { @service system; @service store; @service token; + @service router; queryParams = { region: { @@ -140,7 +141,17 @@ export default class ApplicationRoute extends Route { @action error(error) { if (!(error instanceof AbortError)) { - this.controllerFor('application').set('error', error); + if ( + error.errors?.any( + (e) => + e.detail === 'ACL token expired' || + e.detail === 'ACL token not found' + ) + ) { + this.router.transitionTo('settings.tokens'); + } else { + this.controllerFor('application').set('error', error); + } } } } diff --git a/ui/app/services/token.js b/ui/app/services/token.js index de591393c07..514c9b371be 100644 --- a/ui/app/services/token.js +++ b/ui/app/services/token.js @@ -12,9 +12,12 @@ import classic from 'ember-classic-decorator'; export default class TokenService extends Service { @service store; @service system; + @service router; aclEnabled = true; + tokenNotFound = false; + @computed get secret() { return window.localStorage.nomadTokenSecret; @@ -39,6 +42,9 @@ export default class TokenService extends Service { if (errors.find((error) => error === 'ACL support disabled')) { this.set('aclEnabled', false); } + if (errors.find((error) => error === 'ACL token not found')) { + this.set('tokenNotFound', true); + } return null; } }) diff --git a/ui/app/templates/settings/tokens.hbs b/ui/app/templates/settings/tokens.hbs index d95274ba10d..416fb1133c4 100644 --- a/ui/app/templates/settings/tokens.hbs +++ b/ui/app/templates/settings/tokens.hbs @@ -5,17 +5,43 @@

Clusters that use Access Control Lists require tokens to perform certain tasks. By providing a token Secret ID, each future request will be authenticated, potentially authorizing read access to additional information. By providing a token Accessor ID, the policies and rules for the token will be listed.

-
-
-
-

Token Storage

-

Tokens are stored client-side in local storage. This will persist your token across sessions. You can manually clear your token here.

+ {{#if this.tokenRecord.isExpired}} +
+
+
+

Your token has expired

+

Expired {{moment-from-now this.tokenRecord.expirationTime interval=1000}} ({{this.tokenRecord.expirationTime}})

+
+
+ +
+
+
+ {{else if this.token.tokenNotFound}} +
+
+
+

Your token was not found

+

It may have expired, or been entered incorrectly.

+
+
+ +
-
- +
+ {{else}} +
+
+
+

Token Storage

+

Tokens are stored client-side in local storage. This will persist your token across sessions. You can manually clear your token here.

+
+
+ +
-
+ {{/if}} {{#unless this.tokenIsValid}}
@@ -60,37 +86,42 @@ {{/if}} {{#if this.tokenRecord}} -

Token: {{this.tokenRecord.name}}

-
-
AccessorID: {{this.tokenRecord.accessor}}
-
SecretID: {{this.tokenRecord.secret}}
-
-

Policies

- {{#if (eq this.tokenRecord.type "management")}} -
-
- The management token has all permissions -
+ {{#unless this.tokenRecord.isExpired}} +

Token: {{this.tokenRecord.name}}

+
+
AccessorID: {{this.tokenRecord.accessor}}
+
SecretID: {{this.tokenRecord.secret}}
+ {{#if this.tokenRecord.expirationTime}} +
Expires: {{moment-from-now this.tokenRecord.expirationTime interval=1000}} ({{this.tokenRecord.expirationTime}})
+ {{/if}}
- {{else}} - {{#each this.tokenRecord.policies as |policy|}} -
-
- {{policy.name}} -
-
-

- {{#if policy.description}} - {{policy.description}} - {{else}} - No description - {{/if}} -

-
{{policy.rules}}
+

Policies

+ {{#if (eq this.tokenRecord.type "management")}} +
+
+ The management token has all permissions
- {{/each}} - {{/if}} + {{else}} + {{#each this.tokenRecord.policies as |policy|}} +
+
+ {{policy.name}} +
+
+

+ {{#if policy.description}} + {{policy.description}} + {{else}} + No description + {{/if}} +

+
{{policy.rules}}
+
+
+ {{/each}} + {{/if}} + {{/unless}} {{/if}}
diff --git a/ui/tests/acceptance/token-test.js b/ui/tests/acceptance/token-test.js index e701bb6b1b1..c64fb3b4367 100644 --- a/ui/tests/acceptance/token-test.js +++ b/ui/tests/acceptance/token-test.js @@ -11,6 +11,7 @@ import ClientDetail from 'nomad-ui/tests/pages/clients/detail'; import Layout from 'nomad-ui/tests/pages/layout'; import percySnapshot from '@percy/ember'; import faker from 'nomad-ui/mirage/faker'; +import moment from 'moment'; let job; let node; @@ -181,6 +182,94 @@ module('Acceptance | tokens', function (hooks) { assert.notOk(find('[data-test-job-row]'), 'No jobs found'); }); + test('it handles expiring tokens', async function (assert) { + 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(); + await Tokens.secret(clientToken.secretId).submit(); + assert + .dom('[data-test-token-expiry]') + .doesNotExist('No expiry shown for regular token'); + }); + + test('it handles expired tokens', async function (assert) { + const expiredToken = server.create('token', { + name: 'Well past due', + expirationTime: moment().add(-5, 'm').toDate(), + }); + + // GC'd or non-existent token, from localStorage or otherwise + window.localStorage.nomadTokenSecret = expiredToken.secretId; + await Tokens.visit(); + assert + .dom('[data-test-token-expired]') + .exists('Warning banner shown for expired token'); + }); + + test('it forces redirect on an expired token', async function (assert) { + const expiredToken = server.create('token', { + name: 'Well past due', + expirationTime: moment().add(-5, 'm').toDate(), + }); + + window.localStorage.nomadTokenSecret = expiredToken.secretId; + const expiredServerError = { + errors: [ + { + detail: 'ACL token expired', + }, + ], + }; + server.pretender.get('/v1/jobs', function () { + console.log('uhhhh'); + return [500, {}, JSON.stringify(expiredServerError)]; + }); + + await Jobs.visit(); + assert.equal( + currentURL(), + '/settings/tokens', + 'Redirected to tokens page due to an expired token' + ); + }); + + test('it forces redirect on a not-found token', async function (assert) { + const longDeadToken = server.create('token', { + name: 'dead and gone', + expirationTime: moment().add(-5, 'h').toDate(), + }); + + window.localStorage.nomadTokenSecret = longDeadToken.secretId; + const notFoundServerError = { + errors: [ + { + detail: 'ACL token not found', + }, + ], + }; + server.pretender.get('/v1/jobs', function () { + return [500, {}, JSON.stringify(notFoundServerError)]; + }); + + await Jobs.visit(); + assert.equal( + currentURL(), + '/settings/tokens', + 'Redirected to tokens page due to a token not being found' + ); + }); + test('when the ott query parameter is present upon application load it’s exchanged for a token', async function (assert) { const { oneTimeSecret, secretId } = managementToken;