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