diff --git a/ui/app/components/oidc/key-form.js b/ui/app/components/oidc/key-form.js
index b00e5e96b7..e1cd7cfe24 100644
--- a/ui/app/components/oidc/key-form.js
+++ b/ui/app/components/oidc/key-form.js
@@ -8,6 +8,7 @@ import { action } from '@ember/object';
import { service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
import { task } from 'ember-concurrency';
+import { waitFor } from '@ember/test-waiters';
/**
* @module OidcKeyForm
@@ -26,72 +27,87 @@ import { task } from 'ember-concurrency';
*/
export default class OidcKeyForm extends Component {
- @service store;
+ @service api;
@service flashMessages;
+
@tracked errorBanner;
@tracked invalidFormAlert;
@tracked modelValidations;
- @tracked radioCardGroupValue =
- // If "*" is provided, all clients are allowed: https://developer.hashicorp.com/vault/api-docs/secret/identity/oidc-provider#parameters
- !this.args.model.allowedClientIds || this.args.model.allowedClientIds.includes('*')
- ? 'allow_all'
- : 'limited';
+ @tracked radioCardGroupValue = 'limited';
+ @tracked selectedClients = [];
- get filterDropdownOptions() {
- // query object sent to search-select so only clients that reference this key appear in dropdown
- return { paramKey: 'key', filterFor: [this.args.model.name] };
+ constructor() {
+ super(...arguments);
+ // If "*" is provided, all clients are allowed: https://developer.hashicorp.com/vault/api-docs/secret/identity/oidc-provider#parameters
+ const { allowed_client_ids } = this.args.form.data;
+ if (!allowed_client_ids || allowed_client_ids.includes('*')) {
+ this.radioCardGroupValue = 'allow_all';
+ }
+ // initialize selectedClients for SearchSelect component with allowed_client_ids from form data
+ this.updateSelectedClients();
+ }
+
+ // function passed to search select
+ renderTooltip(selection, dropdownOptions) {
+ // if a client has been deleted it will not exist in dropdownOptions (response from search select's query)
+ const clientExists = !!dropdownOptions.find((opt) => opt.client_id === selection);
+ return !clientExists ? 'The application associated with this client_id no longer exists' : false;
+ }
+
+ updateSelectedClients() {
+ const { data } = this.args.form;
+ this.selectedClients = data.allowed_client_ids?.map((clientId) =>
+ this.args.clients.find((client) => client.client_id === clientId)
+ );
}
@action
handleClientSelection(selection) {
- // if array then coming from search-select component, set selection as model clients
+ const { data } = this.args.form;
+ // when triggered from search-select component an array is passed
+ // set selection as clients
if (Array.isArray(selection)) {
- this.args.model.allowedClientIds = selection.map((client) => client.clientId);
+ data.allowed_client_ids = selection.map((client) => client.client_id);
} else {
// otherwise update radio button value and reset clients so
// UI always reflects a user's selection (including when no clients are selected)
this.radioCardGroupValue = selection;
- this.args.model.allowedClientIds = [];
+ data.allowed_client_ids = [];
}
+ // update selectedClients which appear in SearchSelect
+ this.updateSelectedClients();
}
- @action
- cancel() {
- const method = this.args.model.isNew ? 'unloadRecord' : 'rollbackAttributes';
- this.args.model[method]();
- this.args.onCancel();
- }
+ 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;
- @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;
- if (this.radioCardGroupValue === 'allow_all') {
- this.args.model.allowedClientIds = ['*'];
+ if (isValid) {
+ if (this.radioCardGroupValue === 'allow_all') {
+ data.allowed_client_ids = ['*'];
+ }
+ // if TTL components are toggled off, set to default lease duration
+ const { rotation_period, verification_ttl } = data;
+ // value returned from API is a number, and string when from form action
+ if (Number(rotation_period) === 0) data.rotation_period = '24h';
+ if (Number(verification_ttl) === 0) data.verification_ttl = '24h';
+
+ const { name, ...payload } = data;
+ await this.api.identity.oidcWriteKey(name, payload);
+ this.flashMessages.success(`Successfully ${isNew ? 'created' : 'updated'} the key ${name}.`);
+ // this form is sometimes used in a modal, passing the form notifies the parent the save was successful
+ this.args.onSave(this.args.form);
}
- // if TTL components are toggled off, set to default lease duration
- const { rotationPeriod, verificationTtl } = this.args.model;
- // value returned from API is a number, and string when from form action
- if (Number(rotationPeriod) === 0) this.args.model.rotationPeriod = '24h';
- if (Number(verificationTtl) === 0) this.args.model.verificationTtl = '24h';
- yield this.args.model.save();
- this.flashMessages.success(
- `Successfully ${isNew ? 'created' : 'updated'} the key
- ${name}.`
- );
- // this form is sometimes used in a modal, passing the model notifies
- // the parent if the save was successful
- this.args.onSave(this.args.model);
+ } 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.';
- }
- }
+ })
+ );
}
diff --git a/ui/app/components/oidc/provider-form.js b/ui/app/components/oidc/provider-form.js
index c652455554..2bfab4c1b4 100644
--- a/ui/app/components/oidc/provider-form.js
+++ b/ui/app/components/oidc/provider-form.js
@@ -83,7 +83,7 @@ export default class OidcProviderForm extends Component {
@action
handleClientSelection(selection) {
const { data } = this.args.form;
- // when trigger from search-select component an array is passed
+ // when triggered from search-select component an array is passed
// set selection as clients
if (Array.isArray(selection)) {
data.allowed_client_ids = selection.map((client) => client.client_id);
diff --git a/ui/app/controllers/vault/cluster/access/oidc/keys/key/details.js b/ui/app/controllers/vault/cluster/access/oidc/keys/key/details.js
index 8e8514a6de..7d06f1e84c 100644
--- a/ui/app/controllers/vault/cluster/access/oidc/keys/key/details.js
+++ b/ui/app/controllers/vault/cluster/access/oidc/keys/key/details.js
@@ -10,32 +10,31 @@ import { task } from 'ember-concurrency';
import { waitFor } from '@ember/test-waiters';
export default class OidcKeyDetailsController extends Controller {
- @service store;
+ @service api;
@service router;
@service flashMessages;
- @task
- @waitFor
- *rotateKey() {
- const adapter = this.store.adapterFor('oidc/key');
- yield adapter
- .rotate(this.model.name, this.model.verificationTtl)
- .then(() => {
- this.flashMessages.success(`Success: ${this.model.name} connection was rotated.`);
- })
- .catch((e) => {
- this.flashMessages.danger(e.errors);
- });
- }
+ rotateKey = task(
+ waitFor(async () => {
+ try {
+ const { name, verification_ttl } = this.model.key;
+ await this.api.identity.oidcRotateKey(name, { verification_ttl });
+ this.flashMessages.success(`Success: ${name} connection was rotated.`);
+ } catch (e) {
+ const { message } = await this.api.parseError(e);
+ this.flashMessages.danger(message);
+ }
+ })
+ );
+
@action
async delete() {
try {
- await this.model.destroyRecord();
+ await this.api.identity.oidcDeleteKey(this.model.key.name);
this.flashMessages.success('Key deleted successfully');
this.router.transitionTo('vault.cluster.access.oidc.keys');
} 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/key.ts b/ui/app/forms/oidc/key.ts
new file mode 100644
index 0000000000..034e8bb12a
--- /dev/null
+++ b/ui/app/forms/oidc/key.ts
@@ -0,0 +1,43 @@
+/**
+ * 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 { OidcWriteKeyRequest } from '@hashicorp/vault-client-typescript';
+
+type OidcKeyFormData = OidcWriteKeyRequest & {
+ name: string;
+};
+
+export default class OidcKeyForm extends Form
{
+ formFieldGroups = [
+ new FormFieldGroup('default', [
+ new FormField('name', 'string', { editDisabled: true }),
+ new FormField('algorithm', 'string', {
+ possibleValues: ['RS256', 'RS384', 'RS512', 'ES256', 'ES384', 'ES512', 'EdDSA'],
+ }),
+ new FormField('rotation_period', undefined, {
+ editType: 'ttl',
+ }),
+ new FormField('verification_ttl', undefined, {
+ label: 'Verification TTL',
+ editType: 'ttl',
+ }),
+ ]),
+ ];
+
+ validations: Validations = {
+ name: [
+ { type: 'presence', message: 'Name is required.' },
+ {
+ type: 'containsWhiteSpace',
+ message: 'Name cannot contain whitespace.',
+ },
+ ],
+ };
+}
diff --git a/ui/app/routes/vault/cluster/access/oidc/keys/create.js b/ui/app/routes/vault/cluster/access/oidc/keys/create.js
index fd277ee688..1632b1f444 100644
--- a/ui/app/routes/vault/cluster/access/oidc/keys/create.js
+++ b/ui/app/routes/vault/cluster/access/oidc/keys/create.js
@@ -5,11 +5,17 @@
import Route from '@ember/routing/route';
import { service } from '@ember/service';
+import OidcKeyForm from 'vault/forms/oidc/key';
export default class OidcKeysCreateRoute extends Route {
- @service store;
+ @service api;
model() {
- return this.store.createRecord('oidc/key');
+ const defaultValues = {
+ algorithm: 'RS256',
+ rotation_period: '24h',
+ verification_ttl: '24h',
+ };
+ return new OidcKeyForm(defaultValues, { isNew: true });
}
}
diff --git a/ui/app/routes/vault/cluster/access/oidc/keys/index.js b/ui/app/routes/vault/cluster/access/oidc/keys/index.js
index d0ea016332..796bc78efa 100644
--- a/ui/app/routes/vault/cluster/access/oidc/keys/index.js
+++ b/ui/app/routes/vault/cluster/access/oidc/keys/index.js
@@ -5,17 +5,32 @@
import Route from '@ember/routing/route';
import { service } from '@ember/service';
+import { IdentityApiOidcListKeysListEnum } from '@hashicorp/vault-client-typescript';
export default class OidcKeysRoute extends Route {
- @service store;
+ @service api;
+ @service capabilities;
- model() {
- return this.store.query('oidc/key', {}).catch((err) => {
- if (err.httpStatus === 404) {
- return [];
+ async model() {
+ try {
+ const { keys } = await this.api.identity.oidcListKeys(IdentityApiOidcListKeysListEnum.TRUE);
+ const paths = keys.map((name) => this.capabilities.pathFor('oidcKey', { name }));
+ const capabilities = paths ? await this.capabilities.fetch(paths) : {};
+
+ return {
+ keys,
+ capabilities,
+ };
+ } catch (error) {
+ const { status } = await this.api.parseError(error);
+ if (status === 404) {
+ return {
+ keys: [],
+ capabilities: {},
+ };
} else {
- throw err;
+ throw error;
}
- });
+ }
}
}
diff --git a/ui/app/routes/vault/cluster/access/oidc/keys/key.js b/ui/app/routes/vault/cluster/access/oidc/keys/key.js
index c3e183636b..9a85bf6178 100644
--- a/ui/app/routes/vault/cluster/access/oidc/keys/key.js
+++ b/ui/app/routes/vault/cluster/access/oidc/keys/key.js
@@ -7,9 +7,20 @@ import Route from '@ember/routing/route';
import { service } from '@ember/service';
export default class OidcKeyRoute extends Route {
- @service store;
+ @service api;
+ @service capabilities;
- model({ name }) {
- return this.store.findRecord('oidc/key', name);
+ async model({ name }) {
+ const { data } = await this.api.identity.oidcReadKey(name);
+ const { pathFor } = this.capabilities;
+ const paths = {
+ key: pathFor('oidcKey', { name }),
+ rotate: pathFor('oidcKeyRotate', { name }),
+ };
+ const capabilities = await this.capabilities.fetch(Object.values(paths));
+ return {
+ key: { ...data, name },
+ capabilities: { ...capabilities[paths.key], canRotate: capabilities[paths.rotate].canUpdate },
+ };
}
}
diff --git a/ui/app/routes/vault/cluster/access/oidc/keys/key/clients.js b/ui/app/routes/vault/cluster/access/oidc/keys/key/clients.js
index 78f3346ca4..1ae5b12394 100644
--- a/ui/app/routes/vault/cluster/access/oidc/keys/key/clients.js
+++ b/ui/app/routes/vault/cluster/access/oidc/keys/key/clients.js
@@ -12,13 +12,13 @@ export default class OidcKeyClientsRoute extends Route {
@service capabilities;
async model() {
- const { allowedClientIds } = this.modelFor('vault.cluster.access.oidc.keys.key');
+ const { key } = this.modelFor('vault.cluster.access.oidc.keys.key');
const response = await this.api.identity.oidcListClients(IdentityApiOidcListClientsListEnum.TRUE);
const clients = this.api.keyInfoToArray(response, 'name');
// filter clients based on allowed_client_ids of provider
- const filteredClients = allowedClientIds.includes('*')
+ const filteredClients = key.allowed_client_ids.includes('*')
? clients
- : clients.filter((client) => allowedClientIds.includes(client.client_id));
+ : clients.filter((client) => key.allowed_client_ids.includes(client.client_id));
// fetch capabilities for filtered clients
const paths = filteredClients.map(({ name }) => this.capabilities.pathFor('oidcClient', { name }));
const capabilities = paths ? await this.capabilities.fetch(paths) : {};
diff --git a/ui/app/routes/vault/cluster/access/oidc/keys/key/edit.js b/ui/app/routes/vault/cluster/access/oidc/keys/key/edit.js
index 6306d2b7b4..99cc3dd2d2 100644
--- a/ui/app/routes/vault/cluster/access/oidc/keys/key/edit.js
+++ b/ui/app/routes/vault/cluster/access/oidc/keys/key/edit.js
@@ -4,5 +4,24 @@
*/
import Route from '@ember/routing/route';
+import { service } from '@ember/service';
+import { IdentityApiOidcListClientsListEnum } from '@hashicorp/vault-client-typescript';
+import OidcKeyForm from 'vault/forms/oidc/key';
-export default class OidcKeyEditRoute extends Route {}
+export default class OidcKeyEditRoute extends Route {
+ @service api;
+
+ async model() {
+ const { key } = this.modelFor('vault.cluster.access.oidc.keys.key');
+ // fetch clients to populate dropdown in form
+ const response = await this.api.identity.oidcListClients(IdentityApiOidcListClientsListEnum.TRUE);
+ const clients = this.api.keyInfoToArray(response, 'name');
+ // filter clients that are associated with this key
+ const filteredClients = clients.filter((client) => client.key === key.name);
+
+ return {
+ clients: filteredClients,
+ form: new OidcKeyForm(key),
+ };
+ }
+}
diff --git a/ui/app/templates/vault/cluster/access/oidc/keys/create.hbs b/ui/app/templates/vault/cluster/access/oidc/keys/create.hbs
index 646f78a56c..3d331146cb 100644
--- a/ui/app/templates/vault/cluster/access/oidc/keys/create.hbs
+++ b/ui/app/templates/vault/cluster/access/oidc/keys/create.hbs
@@ -16,7 +16,7 @@
\ No newline at end of file
diff --git a/ui/app/templates/vault/cluster/access/oidc/keys/index.hbs b/ui/app/templates/vault/cluster/access/oidc/keys/index.hbs
index 74541c31a9..ad221ea8e8 100644
--- a/ui/app/templates/vault/cluster/access/oidc/keys/index.hbs
+++ b/ui/app/templates/vault/cluster/access/oidc/keys/index.hbs
@@ -11,18 +11,18 @@
-{{#each this.model as |model|}}
+{{#each this.model.keys as |key|}}
- {{model.name}}
+ {{key}}
@@ -37,16 +37,20 @@
/>
Details
+ >
+ Details
+
Edit
+ >
+ Edit
+
diff --git a/ui/app/templates/vault/cluster/access/oidc/keys/key.hbs b/ui/app/templates/vault/cluster/access/oidc/keys/key.hbs
index 2e426b4725..01e3b909c5 100644
--- a/ui/app/templates/vault/cluster/access/oidc/keys/key.hbs
+++ b/ui/app/templates/vault/cluster/access/oidc/keys/key.hbs
@@ -4,13 +4,13 @@
}}
{{#if (not-eq this.router.currentRoute.localName "edit")}}
-