diff --git a/.changelog/23506.txt b/.changelog/23506.txt
new file mode 100644
index 000000000..b57d6500d
--- /dev/null
+++ b/.changelog/23506.txt
@@ -0,0 +1,3 @@
+```release-note:improvement
+ui: Allow users to create Global ACL tokens from the Administration UI
+```
diff --git a/ui/app/adapters/application.js b/ui/app/adapters/application.js
index 68f6506ab..d5f56964a 100644
--- a/ui/app/adapters/application.js
+++ b/ui/app/adapters/application.js
@@ -59,11 +59,11 @@ export default class ApplicationAdapter extends RESTAdapter {
ajaxOptions(url, verb, options = {}) {
options.data || (options.data = {});
- if (this.get('system.shouldIncludeRegion')) {
+ if (options.regionOverride || this.get('system.shouldIncludeRegion')) {
// Region should only ever be a query param. The default ajaxOptions
// behavior is to include data attributes in the requestBody for PUT
// and POST requests. This works around that.
- const region = this.get('system.activeRegion');
+ const region = options.regionOverride || this.get('system.activeRegion');
if (region) {
url = associateRegion(url, region);
}
diff --git a/ui/app/adapters/token.js b/ui/app/adapters/token.js
index c4d2db900..b436d6a40 100644
--- a/ui/app/adapters/token.js
+++ b/ui/app/adapters/token.js
@@ -31,6 +31,14 @@ export default class TokenAdapter extends ApplicationAdapter {
createRecord(_store, type, snapshot) {
let data = this.serialize(snapshot);
+ if (snapshot.adapterOptions?.region) {
+ // ajaxOptions will try to append a particular region here.
+ // we want instead fo overwrite it with the token's region.
+ return this.ajax(`${this.buildURL()}/token`, 'POST', {
+ data,
+ regionOverride: snapshot.adapterOptions.region,
+ });
+ }
return this.ajax(`${this.buildURL()}/token`, 'POST', { data });
}
@@ -40,6 +48,11 @@ export default class TokenAdapter extends ApplicationAdapter {
}
async findSelf() {
+ // the application adapter automatically adds the region parameter to all requests,
+ // but only if the /regions endpoint has been resolved first. Since this request is async,
+ // we can ensure that the regions are loaded before making the token/self request.
+ await this.system.regions;
+
const response = await this.ajax(`${this.buildURL()}/token/self`, 'GET');
const normalized = this.store.normalize('token', response);
const tokenRecord = this.store.push(normalized);
diff --git a/ui/app/components/region-switcher.js b/ui/app/components/region-switcher.js
index a40e661be..127b19e98 100644
--- a/ui/app/components/region-switcher.js
+++ b/ui/app/components/region-switcher.js
@@ -13,16 +13,20 @@ export default class RegionSwitcher extends Component {
@service system;
@service router;
@service store;
+ @service token;
@computed('system.regions')
get sortedRegions() {
return this.get('system.regions').toArray().sort();
}
- gotoRegion(region) {
- this.router.transitionTo('jobs', {
- queryParams: { region },
- });
+ async gotoRegion(region) {
+ // Note: redundant but as long as we're using PowerSelect, the implicit set('activeRegion')
+ // is not something we can await, so we do it explicitly here.
+ this.system.set('activeRegion', region);
+ await this.get('token.fetchSelfTokenAndPolicies').perform().catch();
+
+ this.router.transitionTo({ queryParams: { region } });
}
get keyCommands() {
diff --git a/ui/app/components/token-editor.hbs b/ui/app/components/token-editor.hbs
index 9045f4a51..da3f32081 100644
--- a/ui/app/components/token-editor.hbs
+++ b/ui/app/components/token-editor.hbs
@@ -95,6 +95,41 @@
{{/unless}}
+ {{#if @token.isNew}}
+ {{#if this.system.shouldShowRegions}}
+
+ Token Region
+ See ACL token fundamentals: Token replication settings for more information.
+
+ {{this.system.activeRegion}}
+
+ {{#if this.system.defaultRegion.region}}
+ {{!-- template-lint-disable simple-unless --}}
+ {{#unless (eq this.system.activeRegion this.system.defaultRegion.region)}}
+
+ {{this.system.defaultRegion.region}} (authoritative region)
+
+ {{/unless}}
+ {{/if}}
+
+ global
+
+
+ {{/if}}
+ {{/if}}
+
Client or Management token?
diff --git a/ui/app/components/token-editor.js b/ui/app/components/token-editor.js
index 3cd8eb161..770910281 100644
--- a/ui/app/components/token-editor.js
+++ b/ui/app/components/token-editor.js
@@ -15,6 +15,7 @@ export default class TokenEditorComponent extends Component {
@service notifications;
@service router;
@service store;
+ @service system;
@alias('args.roles') roles;
@alias('args.token') activeToken;
@@ -23,6 +24,14 @@ export default class TokenEditorComponent extends Component {
@tracked tokenPolicies = [];
@tracked tokenRoles = [];
+ /**
+ * When creating a token, it can be made global (has access to all regions),
+ * or non-global. If it's non-global, it can be scoped to a specific region.
+ * By default, the token is created in the active region of the UI.
+ * @type {string}
+ */
+ @tracked tokenRegion = '';
+
// when this renders, set up tokenPolicies
constructor() {
super(...arguments);
@@ -31,6 +40,7 @@ export default class TokenEditorComponent extends Component {
if (this.activeToken.isNew) {
this.activeToken.expirationTTL = 'never';
}
+ this.tokenRegion = this.system.activeRegion;
}
@action updateTokenPolicies(policy, event) {
@@ -73,6 +83,10 @@ export default class TokenEditorComponent extends Component {
}
}
+ @action updateTokenLocality(event) {
+ this.tokenRegion = event.target.id;
+ }
+
@action async save() {
try {
const shouldRedirectAfterSave = this.activeToken.isNew;
@@ -88,6 +102,12 @@ export default class TokenEditorComponent extends Component {
this.activeToken.roles = [];
}
+ if (this.tokenRegion === 'global') {
+ this.activeToken.global = true;
+ } else {
+ this.activeToken.global = false;
+ }
+
// Sets to "never" for auto-selecting the radio button;
// if it gets updated by the user, will fall back to "" to represent
// no expiration. However, if the user never updates it,
@@ -96,7 +116,13 @@ export default class TokenEditorComponent extends Component {
this.activeToken.expirationTTL = null;
}
- await this.activeToken.save();
+ const adapterRegion = this.activeToken.global
+ ? this.system.get('defaultRegion.region')
+ : this.tokenRegion;
+
+ await this.activeToken.save({
+ adapterOptions: adapterRegion ? { region: adapterRegion } : {},
+ });
this.notifications.add({
title: 'Token Saved',
diff --git a/ui/app/controllers/clients/index.js b/ui/app/controllers/clients/index.js
index ea2bc84bd..006348454 100644
--- a/ui/app/controllers/clients/index.js
+++ b/ui/app/controllers/clients/index.js
@@ -29,7 +29,6 @@ export default class IndexController extends Controller.extend(
@controller('clients') clientsController;
@alias('model.nodes') nodes;
- @alias('model.agents') agents;
queryParams = [
{
diff --git a/ui/app/controllers/jobs/index.js b/ui/app/controllers/jobs/index.js
index 74a2bf4dd..f72f18629 100644
--- a/ui/app/controllers/jobs/index.js
+++ b/ui/app/controllers/jobs/index.js
@@ -233,7 +233,7 @@ export default class JobsIndexController extends Controller {
console.log('error fetching job ids', e);
this.notifyFetchError(e);
}
- if (this.jobs.length) {
+ if (this.jobs?.length) {
return this.jobs;
}
return;
@@ -256,7 +256,7 @@ export default class JobsIndexController extends Controller {
console.log('error fetching job allocs', e);
this.notifyFetchError(e);
}
- if (this.jobs.length) {
+ if (this.jobs?.length) {
return this.jobs;
}
return;
diff --git a/ui/app/routes/clients.js b/ui/app/routes/clients.js
index ef6e9cd8d..e74f9bdc0 100644
--- a/ui/app/routes/clients.js
+++ b/ui/app/routes/clients.js
@@ -22,7 +22,6 @@ export default class ClientsRoute extends Route.extend(WithForbiddenState) {
model() {
return RSVP.hash({
nodes: this.store.findAll('node'),
- agents: this.store.findAll('agent'),
nodePools: this.store.findAll('node-pool'),
}).catch(notifyForbidden(this));
}
diff --git a/ui/mirage/config.js b/ui/mirage/config.js
index dbad607dc..0375daece 100644
--- a/ui/mirage/config.js
+++ b/ui/mirage/config.js
@@ -735,9 +735,8 @@ export default function () {
});
this.post('/acl/token', function (schema, request) {
- const { Name, Policies, Type, ExpirationTTL, ExpirationTime } = JSON.parse(
- request.requestBody
- );
+ const { Name, Policies, Type, ExpirationTTL, ExpirationTime, Global } =
+ JSON.parse(request.requestBody);
function parseDuration(duration) {
const [_, value, unit] = duration.match(/(\d+)(\w)/);
@@ -763,6 +762,7 @@ export default function () {
type: Type,
id: faker.random.uuid(),
expirationTime,
+ global: Global,
createTime: new Date().toISOString(),
});
});
diff --git a/ui/tests/acceptance/regions-test.js b/ui/tests/acceptance/regions-test.js
index d461d597b..48ccdda3b 100644
--- a/ui/tests/acceptance/regions-test.js
+++ b/ui/tests/acceptance/regions-test.js
@@ -158,16 +158,6 @@ module('Acceptance | regions (many)', function (hooks) {
);
});
- test('switching regions on deep pages redirects to the application root', async function (assert) {
- const newRegion = server.db.regions[1].id;
-
- await Allocation.visit({ id: server.db.allocations[0].id });
-
- await selectChoose('[data-test-region-switcher-parent]', newRegion);
-
- assert.ok(currentURL().includes('/jobs?'), 'Back at the jobs page');
- });
-
test('navigating directly to a page with the region query param sets the application to that region', async function (assert) {
const allocation = server.db.allocations[0];
const region = server.db.regions[1].id;
diff --git a/ui/tests/acceptance/token-test.js b/ui/tests/acceptance/token-test.js
index a16723293..c29dd44b2 100644
--- a/ui/tests/acceptance/token-test.js
+++ b/ui/tests/acceptance/token-test.js
@@ -1302,5 +1302,168 @@ module('Acceptance | tokens', function (hooks) {
.dom(expiringTokenExpirationCell)
.hasText('in 2 hours', 'Expiration time is relativized and rounded');
});
+
+ test('When no regions are present, Tokens are by default regional', async function (assert) {
+ await visit('/administration/tokens/new');
+ assert.dom('[data-test-global-token-group]').doesNotExist();
+
+ await fillIn('[data-test-token-name-input]', 'Capt. Steven Hiller');
+ await click('[data-test-token-save]');
+ assert.dom('.flash-message.alert-success').exists();
+ const token = server.db.tokens.findBy(
+ (t) => t.name === 'Capt. Steven Hiller'
+ );
+ assert.false(token.global);
+ });
+ });
+});
+
+module('Tokens and Regions', function (hooks) {
+ setupApplicationTest(hooks);
+ setupMirage(hooks);
+
+ hooks.beforeEach(function () {
+ window.localStorage.clear();
+ window.sessionStorage.clear();
+ faker.seed(1);
+
+ server.create('region', { id: 'america' });
+ server.create('region', { id: 'washington-dc' });
+ server.create('region', { id: 'new-york' });
+ server.create('region', { id: 'alien-ship' });
+
+ server.create('agent');
+ server.create('node-pool');
+ server.create('namespace');
+ node = server.create('node');
+ job = server.create('job');
+ managementToken = server.create('token');
+
+ window.localStorage.nomadTokenSecret = managementToken.secretId;
+ });
+ test('When regions are present, Tokens are by default regional, but can be made global', async function (assert) {
+ await visit('/administration/tokens/new');
+ assert.dom('[data-test-global-token-group]').exists();
+ });
+
+ test('A global token can be created, and gets saved in the authoritative region', async function (assert) {
+ await visit('/administration/tokens/new');
+ assert
+ .dom('[data-test-active-region-label]')
+ .hasText('america', 'america is the default selected region');
+ assert
+ .dom('[data-test-locality]')
+ .exists(
+ { count: 2 },
+ 'When in the authoritative/default region, only it and global are region options'
+ );
+
+ // change region from dropdown
+ await selectChoose('[data-test-region-switcher-parent]', 'washington-dc');
+
+ assert
+ .dom('[data-test-active-region-label]')
+ .hasText('washington-dc', 'washington-dc is the selected region');
+ assert.dom('[data-test-locality="active-region"]').isChecked();
+
+ assert
+ .dom('[data-test-locality]')
+ .exists(
+ { count: 3 },
+ 'When in a region other than the authoritative one, the authoritative group becomes an third option in addition to current region and global'
+ );
+
+ await fillIn('[data-test-token-name-input]', 'Thomas J. Whitmore');
+ await click('[data-test-locality="global"]');
+ assert.dom('[data-test-locality="global"]').isChecked();
+
+ await click('[data-test-token-type="management"]');
+ await click('[data-test-token-save]');
+
+ let globalToken = server.db.tokens.findBy(
+ (t) => t.name === 'Thomas J. Whitmore'
+ );
+ assert.ok(globalToken.global, 'Token has Global set to true');
+ assert.dom('.flash-message.alert-success').exists();
+ let tokenRequest = server.pretender.handledRequests.find((req) => {
+ return req.url.includes('acl/token') && req.method === 'POST';
+ });
+ assert.equal(
+ tokenRequest.queryParams.region,
+ 'america',
+ 'Global token is saved in the authoritative region, regardless of active UI region'
+ );
+ await percySnapshot(assert);
+ });
+
+ test('A token can be created in a non-authoritative region', async function (assert) {
+ await visit('/administration/tokens/new');
+ assert
+ .dom('[data-test-active-region-label]')
+ .hasText('america', 'america is the default selected region');
+ assert
+ .dom('[data-test-locality]')
+ .exists(
+ { count: 2 },
+ 'When in the authoritative/default region, only it and global are region options'
+ );
+
+ // change region from dropdown
+ await selectChoose('[data-test-region-switcher-parent]', 'alien-ship');
+
+ assert
+ .dom('[data-test-active-region-label]')
+ .hasText('alien-ship', 'alien-ship is the selected region');
+ assert.dom('[data-test-locality="active-region"]').isChecked();
+
+ await fillIn('[data-test-token-name-input]', 'David Levinson');
+ await click('[data-test-token-type="management"]');
+ await click('[data-test-token-save]');
+ assert.dom('.flash-message.alert-success').exists();
+ let token = server.db.tokens.findBy((t) => t.name === 'David Levinson');
+
+ assert.notOk(token.global, 'Token is not global');
+ const tokenRequest = server.pretender.handledRequests.find((req) => {
+ return req.url.includes('acl/token') && req.method === 'POST';
+ });
+
+ assert.equal(
+ tokenRequest.queryParams.region,
+ 'alien-ship',
+ 'Token is saved in the selected region'
+ );
+ });
+
+ test('A non-global token can be created in the authoritative region', async function (assert) {
+ await visit('/administration/tokens/new');
+
+ // change region from dropdown
+ await selectChoose('[data-test-region-switcher-parent]', 'new-york');
+
+ assert
+ .dom('[data-test-active-region-label]')
+ .hasText('new-york', 'new-york is the selected region');
+ assert.dom('[data-test-locality="active-region"]').isChecked();
+
+ await click('[data-test-locality="default-region"]');
+ assert.dom('[data-test-locality="default-region"]').isChecked();
+
+ await fillIn('[data-test-token-name-input]', 'Russell Casse');
+ await click('[data-test-token-type="management"]');
+ // await this.pauseTest();
+
+ await click('[data-test-token-save]');
+ assert.dom('.flash-message.alert-success').exists();
+ let token = server.db.tokens.findBy((t) => t.name === 'Russell Casse');
+ assert.notOk(token.global, 'Token is not global');
+ const tokenRequest = server.pretender.handledRequests.find((req) => {
+ return req.url.includes('acl/token') && req.method === 'POST';
+ });
+
+ assert.equal(
+ tokenRequest.queryParams.region,
+ 'america',
+ 'Token is saved in the authoritative region'
+ );
});
});