From 7d474e2d8c2a07cbb6fea5a0be6904b4b5883e0f Mon Sep 17 00:00:00 2001
From: Vault Automation
Date: Wed, 6 May 2026 14:01:22 -0600
Subject: [PATCH] [UI] Ember Data Migration - OIDC Scopes (#14488) (#14520)
* updates oidc scope list view to use api service
* updates oidc scope details route to use api service
* updates oidc scopes edit and create views to use api service and form class
* updates oidc scopes tests
Co-authored-by: Jordan Reimer
---
ui/app/components/oidc/scope-form.hbs | 10 +-
ui/app/components/oidc/scope-form.js | 61 ++++----
.../access/oidc/scopes/scope/details.js | 6 +-
ui/app/forms/oidc/scope.ts | 29 ++++
.../cluster/access/oidc/scopes/create.js | 6 +-
.../vault/cluster/access/oidc/scopes/index.js | 29 +++-
.../vault/cluster/access/oidc/scopes/scope.js | 12 +-
.../cluster/access/oidc/scopes/scope/edit.js | 8 +-
.../cluster/access/oidc/scopes/create.hbs | 4 +-
.../cluster/access/oidc/scopes/index.hbs | 30 ++--
.../access/oidc/scopes/scope/details.hbs | 104 ++++++-------
.../cluster/access/oidc/scopes/scope/edit.hbs | 6 +-
ui/app/utils/constants/capabilities.ts | 1 +
.../oidc-config/providers-scopes-test.js | 14 +-
.../components/oidc/scope-form-test.js | 138 ++++++------------
15 files changed, 237 insertions(+), 221 deletions(-)
create mode 100644 ui/app/forms/oidc/scope.ts
diff --git a/ui/app/components/oidc/scope-form.hbs b/ui/app/components/oidc/scope-form.hbs
index 5c57a2c9ef..d11ab01530 100644
--- a/ui/app/components/oidc/scope-form.hbs
+++ b/ui/app/components/oidc/scope-form.hbs
@@ -3,7 +3,7 @@
SPDX-License-Identifier: BUSL-1.1
}}
-
+
<:breadcrumbs>
@@ -24,13 +24,11 @@
Providers may reference a set of scopes to make specific identity information available as claims
- {{#each @model.formFields as |field|}}
-
- {{/each}}
+
diff --git a/ui/app/components/oidc/scope-form.js b/ui/app/components/oidc/scope-form.js
index a730368cd7..1421d65374 100644
--- a/ui/app/components/oidc/scope-form.js
+++ b/ui/app/components/oidc/scope-form.js
@@ -5,9 +5,9 @@
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
-import { action } from '@ember/object';
import { task } from 'ember-concurrency';
import { service } from '@ember/service';
+import { waitFor } from '@ember/test-waiters';
/**
* @module OidcScopeForm
@@ -15,17 +15,19 @@ import { service } from '@ember/service';
*
* @example
* ```js
- *
+ *
* ```
* @callback onCancel
* @callback onSave
- * @param {Object} model - oidc scope model
+ * @param {Object} form - oidc scope form
* @param {onCancel} onCancel - callback triggered when cancel button is clicked
* @param {onSave} onSave - callback triggered on save success
*/
export default class OidcScopeFormComponent extends Component {
+ @service api;
@service flashMessages;
+
@tracked errorBanner;
@tracked invalidFormAlert;
@tracked modelValidations;
@@ -45,41 +47,38 @@ export default class OidcScopeFormComponent extends Component {
{ label: 'OIDC provider: Scopes', route: 'vault.cluster.access.oidc.scopes' },
];
- if (!this.args.model.isNew) {
+ if (!this.args.form.isNew) {
crumbs.push({
- label: this.args.model.name,
+ label: this.args.form.data.name,
route: 'vault.cluster.access.oidc.scopes.scope.details',
- model: this.args.model.name,
+ model: this.args.form.data.name,
});
}
- crumbs.push({ label: this.args.model.isNew ? 'Create scope' : 'Edit scope' });
+ crumbs.push({ label: this.args.form.isNew ? 'Create scope' : 'Edit scope' });
return crumbs;
}
- @task
- *save(event) {
- event.preventDefault();
- try {
- const { isValid, state, invalidFormMessage } = this.args.model.validate();
- this.modelValidations = isValid ? null : state;
- this.invalidFormAlert = invalidFormMessage;
- if (isValid) {
- const { isNew, name } = this.args.model;
- yield this.args.model.save();
- this.flashMessages.success(`Successfully ${isNew ? 'created' : 'updated'} the scope ${name}.`);
- this.args.onSave();
+ save = task(
+ waitFor(async (event) => {
+ event.preventDefault();
+ try {
+ const { isNew } = this.args.form;
+ const { isValid, state, invalidFormMessage, data } = this.args.form.toJSON();
+ this.modelValidations = isValid ? null : state;
+ this.invalidFormAlert = invalidFormMessage;
+
+ if (isValid) {
+ const { name, ...payload } = data;
+ await this.api.identity.oidcWriteScope(name, payload);
+ this.flashMessages.success(`Successfully ${isNew ? 'created' : 'updated'} the scope ${name}.`);
+ this.args.onSave();
+ }
+ } catch (error) {
+ const { message } = await this.api.parseError(error);
+ this.errorBanner = message;
+ this.invalidFormAlert = 'There was an error submitting this form.';
}
- } catch (error) {
- const message = error.errors ? error.errors.join('. ') : error.message;
- this.errorBanner = message;
- this.invalidFormAlert = 'There was an error submitting this form.';
- }
- }
- @action
- cancel() {
- const method = this.args.model.isNew ? 'unloadRecord' : 'rollbackAttributes';
- this.args.model[method]();
- this.args.onCancel();
- }
+ })
+ );
}
diff --git a/ui/app/controllers/vault/cluster/access/oidc/scopes/scope/details.js b/ui/app/controllers/vault/cluster/access/oidc/scopes/scope/details.js
index e8ada25f67..79153c46dc 100644
--- a/ui/app/controllers/vault/cluster/access/oidc/scopes/scope/details.js
+++ b/ui/app/controllers/vault/cluster/access/oidc/scopes/scope/details.js
@@ -8,18 +8,18 @@ import { action } from '@ember/object';
import { service } from '@ember/service';
export default class OidcScopeDetailsController extends Controller {
+ @service api;
@service router;
@service flashMessages;
@action
async delete() {
try {
- await this.model.destroyRecord();
+ await this.api.identity.oidcDeleteScope(this.model.scope.name);
this.flashMessages.success('Scope deleted successfully');
this.router.transitionTo('vault.cluster.access.oidc.scopes');
} catch (error) {
- this.model.rollbackAttributes();
- const message = error.errors ? error.errors.join('. ') : error.message;
+ const { message } = await this.api.parseError(error);
this.flashMessages.danger(message);
}
}
diff --git a/ui/app/forms/oidc/scope.ts b/ui/app/forms/oidc/scope.ts
new file mode 100644
index 0000000000..8b7fda85ab
--- /dev/null
+++ b/ui/app/forms/oidc/scope.ts
@@ -0,0 +1,29 @@
+/**
+ * Copyright IBM Corp. 2016, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import Form from 'vault/forms/form';
+import FormField from 'vault/utils/forms/field';
+import FormFieldGroup from 'vault/utils/forms/field-group';
+
+import type { Validations } from 'vault/app-types';
+import type { OidcWriteScopeRequest } from '@hashicorp/vault-client-typescript';
+
+type OidcScopeFormData = OidcWriteScopeRequest & {
+ name: string;
+};
+
+export default class OidcScopeForm extends Form {
+ formFieldGroups = [
+ new FormFieldGroup('default', [
+ new FormField('name', 'string', { editDisabled: true }),
+ new FormField('description', 'string', { editType: 'textarea' }),
+ new FormField('template', 'string', { label: 'JSON Template', editType: 'json', mode: 'ruby' }),
+ ]),
+ ];
+
+ validations: Validations = {
+ name: [{ type: 'presence', message: 'Name is required.' }],
+ };
+}
diff --git a/ui/app/routes/vault/cluster/access/oidc/scopes/create.js b/ui/app/routes/vault/cluster/access/oidc/scopes/create.js
index 91db3f0aef..cb1641de76 100644
--- a/ui/app/routes/vault/cluster/access/oidc/scopes/create.js
+++ b/ui/app/routes/vault/cluster/access/oidc/scopes/create.js
@@ -4,12 +4,10 @@
*/
import Route from '@ember/routing/route';
-import { service } from '@ember/service';
+import OidcScopeForm from 'vault/forms/oidc/scope';
export default class OidcScopesCreateRoute extends Route {
- @service store;
-
model() {
- return this.store.createRecord('oidc/scope');
+ return new OidcScopeForm({}, { isNew: true });
}
}
diff --git a/ui/app/routes/vault/cluster/access/oidc/scopes/index.js b/ui/app/routes/vault/cluster/access/oidc/scopes/index.js
index 514fe112c6..2a3333ee87 100644
--- a/ui/app/routes/vault/cluster/access/oidc/scopes/index.js
+++ b/ui/app/routes/vault/cluster/access/oidc/scopes/index.js
@@ -5,17 +5,32 @@
import Route from '@ember/routing/route';
import { service } from '@ember/service';
+import { IdentityApiOidcListScopesListEnum } from '@hashicorp/vault-client-typescript';
export default class OidcScopesRoute extends Route {
- @service store;
+ @service api;
+ @service capabilities;
- model() {
- return this.store.query('oidc/scope', {}).catch((err) => {
- if (err.httpStatus === 404) {
- return [];
+ async model() {
+ try {
+ const { keys: scopes } = await this.api.identity.oidcListScopes(IdentityApiOidcListScopesListEnum.TRUE);
+ const paths = scopes.map((name) => this.capabilities.pathFor('oidcScope', { name }));
+ const capabilities = paths ? await this.capabilities.fetch(paths) : {};
+
+ return {
+ scopes,
+ capabilities,
+ };
+ } catch (error) {
+ const { status } = await this.api.parseError(error);
+ if (status === 404) {
+ return {
+ scopes: [],
+ capabilities: {},
+ };
} else {
- throw err;
+ throw error;
}
- });
+ }
}
}
diff --git a/ui/app/routes/vault/cluster/access/oidc/scopes/scope.js b/ui/app/routes/vault/cluster/access/oidc/scopes/scope.js
index f586dbfcc0..e4bb9a4450 100644
--- a/ui/app/routes/vault/cluster/access/oidc/scopes/scope.js
+++ b/ui/app/routes/vault/cluster/access/oidc/scopes/scope.js
@@ -7,9 +7,15 @@ import Route from '@ember/routing/route';
import { service } from '@ember/service';
export default class OidcScopeRoute extends Route {
- @service store;
+ @service api;
+ @service capabilities;
- model({ name }) {
- return this.store.findRecord('oidc/scope', name);
+ async model({ name }) {
+ const { data } = await this.api.identity.oidcReadScope(name);
+ const capabilities = await this.capabilities.for('oidcScope', { name });
+ return {
+ scope: { ...data, name },
+ capabilities,
+ };
}
}
diff --git a/ui/app/routes/vault/cluster/access/oidc/scopes/scope/edit.js b/ui/app/routes/vault/cluster/access/oidc/scopes/scope/edit.js
index 339756fb37..3be895fe5a 100644
--- a/ui/app/routes/vault/cluster/access/oidc/scopes/scope/edit.js
+++ b/ui/app/routes/vault/cluster/access/oidc/scopes/scope/edit.js
@@ -4,5 +4,11 @@
*/
import Route from '@ember/routing/route';
+import OidcScopeForm from 'vault/forms/oidc/scope';
-export default class OidcScopeEditRoute extends Route {}
+export default class OidcScopeEditRoute extends Route {
+ model() {
+ const { scope } = this.modelFor('vault.cluster.access.oidc.scopes.scope');
+ return new OidcScopeForm(scope);
+ }
+}
diff --git a/ui/app/templates/vault/cluster/access/oidc/scopes/create.hbs b/ui/app/templates/vault/cluster/access/oidc/scopes/create.hbs
index b278e52e8e..e389156fea 100644
--- a/ui/app/templates/vault/cluster/access/oidc/scopes/create.hbs
+++ b/ui/app/templates/vault/cluster/access/oidc/scopes/create.hbs
@@ -4,7 +4,7 @@
}}
\ No newline at end of file
diff --git a/ui/app/templates/vault/cluster/access/oidc/scopes/index.hbs b/ui/app/templates/vault/cluster/access/oidc/scopes/index.hbs
index b1a6fccf09..76346d9f3a 100644
--- a/ui/app/templates/vault/cluster/access/oidc/scopes/index.hbs
+++ b/ui/app/templates/vault/cluster/access/oidc/scopes/index.hbs
@@ -11,19 +11,19 @@
-{{#if (gt this.model.length 0)}}
- {{#each this.model as |model|}}
+{{#if this.model.scopes}}
+ {{#each this.model.scopes as |scope|}}
- {{model.name}}
+ {{scope}}
@@ -38,16 +38,24 @@
/>
Details
+ >
+ Details
+
Edit
+ >
+ Edit
+
diff --git a/ui/app/templates/vault/cluster/access/oidc/scopes/scope/details.hbs b/ui/app/templates/vault/cluster/access/oidc/scopes/scope/details.hbs
index 1c86e707f4..09c39e80a7 100644
--- a/ui/app/templates/vault/cluster/access/oidc/scopes/scope/details.hbs
+++ b/ui/app/templates/vault/cluster/access/oidc/scopes/scope/details.hbs
@@ -3,56 +3,58 @@
SPDX-License-Identifier: BUSL-1.1
}}
-
- <:breadcrumbs>
-
-
-
-
-
-
-
-
- {{#if this.model.canDelete}}
-
+ <:breadcrumbs>
+
-
- {{/if}}
- {{#if this.model.canEdit}}
-
- Edit scope
-
- {{/if}}
-
-
+
+
-
-
-
-
-
- JSON Template
-
-
-
\ No newline at end of file
+
+
+
+
+ {{#if capabilities.canDelete}}
+
+
+ {{/if}}
+ {{#if capabilities.canUpdate}}
+
+ Edit scope
+
+ {{/if}}
+
+
+
+
+
+
+
+
+ JSON Template
+
+
+
+{{/let}}
\ No newline at end of file
diff --git a/ui/app/templates/vault/cluster/access/oidc/scopes/scope/edit.hbs b/ui/app/templates/vault/cluster/access/oidc/scopes/scope/edit.hbs
index a11480c5dc..40e40f1ae6 100644
--- a/ui/app/templates/vault/cluster/access/oidc/scopes/scope/edit.hbs
+++ b/ui/app/templates/vault/cluster/access/oidc/scopes/scope/edit.hbs
@@ -4,7 +4,7 @@
}}
\ No newline at end of file
diff --git a/ui/app/utils/constants/capabilities.ts b/ui/app/utils/constants/capabilities.ts
index 85c78ba1a1..13178d1f99 100644
--- a/ui/app/utils/constants/capabilities.ts
+++ b/ui/app/utils/constants/capabilities.ts
@@ -40,6 +40,7 @@ export const PATH_MAP = {
ldapStaticRoleCreds: apiPath`${'backend'}/static-cred/${'name'}`,
oidcClient: apiPath`identity/oidc/client/${'name'}`,
oidcProvider: apiPath`identity/oidc/provider/${'name'}`,
+ oidcScope: apiPath`identity/oidc/scope/${'name'}`,
pkiCertificates: apiPath`${'backend'}/certificates`,
pkiConfigAcme: apiPath`${'backend'}/config/acme`,
pkiConfigAutoTidy: apiPath`${'backend'}/config/auto-tidy`,
diff --git a/ui/tests/acceptance/oidc-config/providers-scopes-test.js b/ui/tests/acceptance/oidc-config/providers-scopes-test.js
index 6dba52543a..9312fbd1c8 100644
--- a/ui/tests/acceptance/oidc-config/providers-scopes-test.js
+++ b/ui/tests/acceptance/oidc-config/providers-scopes-test.js
@@ -23,7 +23,6 @@ import {
SCOPE_DATA_RESPONSE,
PROVIDER_LIST_RESPONSE,
PROVIDER_DATA_RESPONSE,
- clearRecord,
} from 'vault/tests/helpers/oidc-config';
import { capabilitiesStub, overrideResponse } from 'vault/tests/helpers/stubs';
import sinon from 'sinon';
@@ -39,7 +38,6 @@ module('Acceptance | oidc-config providers and scopes', function (hooks) {
hooks.beforeEach(function () {
oidcConfigHandlers(this.server);
- this.store = this.owner.lookup('service:store');
this.api = this.owner.lookup('service:api');
// mock client list so OIDC BASE URL does not redirect to landing call-to-action image
this.server.get('/identity/oidc/client', () => overrideResponse(null, { data: CLIENT_LIST_RESPONSE }));
@@ -49,7 +47,13 @@ module('Acceptance | oidc-config providers and scopes', function (hooks) {
// LIST SCOPES EMPTY
test('it navigates to scopes list view and renders empty state when no scopes are configured', async function (assert) {
assert.expect(4);
- this.server.get('/identity/oidc/scope', () => overrideResponse(404));
+ this.server.get(
+ '/identity/oidc/scope',
+ () => ({
+ errors: ['Nothing found'],
+ }),
+ 404
+ );
await visit(OIDC_BASE_URL);
await click(GENERAL.tab('scopes'));
assert.strictEqual(currentURL(), '/vault/access/oidc/scopes');
@@ -168,11 +172,11 @@ module('Acceptance | oidc-config providers and scopes', function (hooks) {
test('it creates a scope, and creates a provider with that scope', async function (assert) {
assert.expect(28);
- const apiSpy = sinon.spy(this.owner.lookup('service:api').identity, 'oidcWriteProvider');
+ const apiSpy = sinon.spy(this.api.identity, 'oidcWriteProvider');
//* clear out test state
await Promise.allSettled([
- clearRecord(this.store, 'oidc/scope', 'test-scope'),
+ this.api.identity.oidcDeleteScope('test-scope'),
this.api.identity.oidcDeleteProvider('test-provider'),
]);
diff --git a/ui/tests/integration/components/oidc/scope-form-test.js b/ui/tests/integration/components/oidc/scope-form-test.js
index e735e8d30e..64772f8ae6 100644
--- a/ui/tests/integration/components/oidc/scope-form-test.js
+++ b/ui/tests/integration/components/oidc/scope-form-test.js
@@ -8,36 +8,38 @@ import { setupRenderingTest } from 'vault/tests/helpers';
import { render, fillIn, click } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
import { setupMirage } from 'ember-cli-mirage/test-support';
-import { SELECTORS, OIDC_BASE_URL } from 'vault/tests/helpers/oidc-config';
-import { capabilitiesStub } from 'vault/tests/helpers/stubs';
+import { SELECTORS } from 'vault/tests/helpers/oidc-config';
import { GENERAL } from 'vault/tests/helpers/general-selectors';
+import sinon from 'sinon';
+import OidcScopeForm from 'vault/forms/oidc/scope';
+import { getErrorResponse } from 'vault/tests/helpers/api/error-response';
module('Integration | Component | oidc/scope-form', function (hooks) {
setupRenderingTest(hooks);
setupMirage(hooks);
hooks.beforeEach(function () {
- this.store = this.owner.lookup('service:store');
+ const api = this.owner.lookup('service:api');
+ this.writeStub = sinon.stub(api.identity, 'oidcWriteScope').resolves();
+ this.onCancel = sinon.spy();
+ this.onSave = sinon.spy();
+
+ this.renderComponent = (scope) => {
+ this.form = new OidcScopeForm(scope || {}, { isNew: !scope });
+ return render(hbs`
+
+ `);
+ };
});
test('it should save new scope', async function (assert) {
assert.expect(8);
- this.server.post('/identity/oidc/scope/test', (schema, req) => {
- assert.ok(true, 'Request made to save scope');
- return JSON.parse(req.requestBody);
- });
-
- this.model = this.store.createRecord('oidc/scope');
- this.onSave = () => assert.ok(true, 'onSave callback fires on save success');
-
- await render(hbs`
-
- `);
+ await this.renderComponent();
assert.dom(GENERAL.hdsPageHeaderTitle).hasText('Create Scope', 'Form title renders');
assert.dom(SELECTORS.scopeSaveButton).hasText('Create', 'Save button has correct label');
@@ -62,31 +64,18 @@ module('Integration | Component | oidc/scope-form', function (hooks) {
await fillIn(GENERAL.inputByAttr('name'), 'test');
await fillIn(GENERAL.inputByAttr('description'), 'this is a test');
await click(SELECTORS.scopeSaveButton);
+
+ assert.true(this.onSave.calledOnce, 'onSave callback is called on successful save');
+ assert.true(
+ this.writeStub.calledWith('test', { description: 'this is a test' }),
+ 'API is called with correct parameters'
+ );
});
test('it should update scope', async function (assert) {
assert.expect(9);
- this.server.post('/identity/oidc/scope/test', (schema, req) => {
- assert.ok(true, 'Request made to save scope');
- return JSON.parse(req.requestBody);
- });
-
- this.store.pushPayload('oidc/scope', {
- modelName: 'oidc/scope',
- name: 'test',
- description: 'this is a test',
- });
- this.model = this.store.peekRecord('oidc/scope', 'test');
- this.onSave = () => assert.ok(true, 'onSave callback fires on save success');
-
- await render(hbs`
-
- `);
+ await this.renderComponent({ name: 'test', description: 'this is a test' });
assert.dom(GENERAL.hdsPageHeaderTitle).hasText('Edit Scope', 'Form title renders');
assert.dom(SELECTORS.scopeSaveButton).hasText('Update', 'Save button has correct label');
@@ -105,62 +94,28 @@ module('Integration | Component | oidc/scope-form', function (hooks) {
await fillIn(GENERAL.inputByAttr('description'), 'this is an edit test');
await click(SELECTORS.scopeSaveButton);
+
+ assert.true(this.onSave.calledOnce, 'onSave callback is called on successful save');
+ assert.true(
+ this.writeStub.calledWith('test', { description: 'this is an edit test' }),
+ 'API is called with correct parameters'
+ );
});
- test('it should rollback attributes or unload record on cancel', async function (assert) {
- assert.expect(4);
-
- this.onCancel = () => assert.ok(true, 'onCancel callback fires');
-
- this.model = this.store.createRecord('oidc/scope');
-
- await render(hbs`
-
- `);
+ test('it should trigger on cancel callback', async function (assert) {
+ assert.expect(1);
+ await this.renderComponent();
await click(SELECTORS.scopeCancelButton);
- assert.true(this.model.isDestroyed, 'New model is unloaded on cancel');
-
- this.store.pushPayload('oidc/scope', {
- modelName: 'oidc/scope',
- name: 'test',
- description: 'this is a test',
- });
- this.model = this.store.peekRecord('oidc/scope', 'test');
-
- await render(hbs`
-
- `);
-
- await fillIn(GENERAL.inputByAttr('description'), 'changed description attribute');
- await click(SELECTORS.scopeCancelButton);
- assert.strictEqual(
- this.model.description,
- 'this is a test',
- 'Model attributes are rolled back on cancel'
- );
+ assert.true(this.onCancel.calledOnce, 'onCancel callback is called when cancel button is clicked');
});
test('it should show example template modal', async function (assert) {
assert.expect(5);
- const MODAL = (e) => `[data-test-scope-modal="${e}"]`;
- this.model = this.store.createRecord('oidc/scope');
- await render(hbs`
-
- `);
+ const MODAL = (e) => `[data-test-scope-modal="${e}"]`;
+
+ await this.renderComponent();
await click('[data-test-oidc-scope-example]');
assert.dom(MODAL('title')).hasText('Scope template', 'Modal title renders');
@@ -173,15 +128,10 @@ module('Integration | Component | oidc/scope-form', function (hooks) {
test('it should render error alerts when API returns an error', async function (assert) {
assert.expect(2);
- this.model = this.store.createRecord('oidc/scope');
- this.server.post('/sys/capabilities-self', () => capabilitiesStub(OIDC_BASE_URL + '/scopes'));
- await render(hbs`
-
- `);
+
+ this.writeStub.rejects(getErrorResponse());
+ await this.renderComponent();
+
await fillIn(GENERAL.inputByAttr('name'), 'test-scope');
await click(SELECTORS.scopeSaveButton);
assert