diff --git a/changelog/17071.txt b/changelog/17071.txt new file mode 100644 index 000000000..926ca839a --- /dev/null +++ b/changelog/17071.txt @@ -0,0 +1,2 @@ +```release-note:feature +**UI OIDC Provider Config**: Adds configuration of Vault as an OIDC identity provider, and offer Vault’s various authentication methods and source of identity to any client applications. \ No newline at end of file diff --git a/ui/app/adapters/named-path.js b/ui/app/adapters/named-path.js new file mode 100644 index 000000000..383fa75b5 --- /dev/null +++ b/ui/app/adapters/named-path.js @@ -0,0 +1,76 @@ +/** + * base adapter for resources that are saved to a path whose unique identifier is name + * save requests are made to the same endpoint and the resource is either created if not found or updated + * */ +import ApplicationAdapter from './application'; +import { assert } from '@ember/debug'; +export default class NamedPathAdapter extends ApplicationAdapter { + namespace = 'v1'; + saveMethod = 'POST'; // override when extending if PUT is used rather than POST + + _saveRecord(store, { modelName }, snapshot) { + // since the response is empty return the serialized data rather than nothing + const data = store.serializerFor(modelName).serialize(snapshot); + return this.ajax(this.urlForUpdateRecord(snapshot.attr('name'), modelName, snapshot), this.saveMethod, { + data, + }).then(() => data); + } + + // create does not return response similar to PUT request + createRecord() { + let [store, { modelName }, snapshot] = arguments; + let name = snapshot.attr('name'); + // throw error if user attempts to create a record with same name, otherwise POST request silently overrides (updates) the existing model + if (store.hasRecordForId(modelName, name)) { + throw new Error(`A record already exists with the name: ${name}`); + } else { + return this._saveRecord(...arguments); + } + } + + // update uses same endpoint and method as create + updateRecord() { + return this._saveRecord(...arguments); + } + + // if backend does not return name in response Ember Data will throw an error for pushing a record with no id + // use the id (name) supplied to findRecord to set property on response data + findRecord(store, type, name) { + return super.findRecord(...arguments).then((resp) => { + if (!resp.data.name) { + resp.data.name = name; + } + return resp; + }); + } + + // GET request with list=true as query param + async query(store, type, query) { + const url = this.urlForQuery(query, type.modelName); + const { paramKey, filterFor, allowed_client_id } = query; + // * 'paramKey' is a string of the param name (model attr) we're filtering for, e.g. 'client_id' + // * 'filterFor' is an array of values to filter for (value type must match the attr type), e.g. array of ID strings + // * 'allowed_client_id' is a valid query param to the /provider endpoint + let queryParams = { list: true, ...(allowed_client_id && { allowed_client_id }) }; + const response = await this.ajax(url, 'GET', { data: queryParams }); + + // filter LIST response only if key_info exists and query includes both 'paramKey' & 'filterFor' + if (filterFor) assert('filterFor must be an array', Array.isArray(filterFor)); + if (response.data.key_info && filterFor && paramKey && !filterFor.includes('*')) { + const data = this.filterListResponse(paramKey, filterFor, response.data.key_info); + return { ...response, data }; + } + return response; + } + + filterListResponse(paramKey, matchValues, key_info) { + const keyInfoAsArray = Object.entries(key_info); + const filtered = keyInfoAsArray.filter((key) => { + const value = key[1]; // value is an object of model attributes + return matchValues.includes(value[paramKey]); + }); + const filteredKeyInfo = Object.fromEntries(filtered); + const filteredKeys = Object.keys(filteredKeyInfo); + return { keys: filteredKeys, key_info: filteredKeyInfo }; + } +} diff --git a/ui/app/adapters/oidc/assignment.js b/ui/app/adapters/oidc/assignment.js new file mode 100644 index 000000000..0c78f6492 --- /dev/null +++ b/ui/app/adapters/oidc/assignment.js @@ -0,0 +1,7 @@ +import NamedPathAdapter from '../named-path'; + +export default class OidcAssignmentAdapter extends NamedPathAdapter { + pathForType() { + return 'identity/oidc/assignment'; + } +} diff --git a/ui/app/adapters/oidc/client.js b/ui/app/adapters/oidc/client.js new file mode 100644 index 000000000..3331b6d7e --- /dev/null +++ b/ui/app/adapters/oidc/client.js @@ -0,0 +1,7 @@ +import NamedPathAdapter from '../named-path'; + +export default class OidcClientAdapter extends NamedPathAdapter { + pathForType() { + return 'identity/oidc/client'; + } +} diff --git a/ui/app/adapters/oidc/key.js b/ui/app/adapters/oidc/key.js new file mode 100644 index 000000000..0b7561ed0 --- /dev/null +++ b/ui/app/adapters/oidc/key.js @@ -0,0 +1,11 @@ +import NamedPathAdapter from '../named-path'; + +export default class OidcKeyAdapter extends NamedPathAdapter { + pathForType() { + return 'identity/oidc/key'; + } + rotate(name, verification_ttl) { + const data = verification_ttl ? { verification_ttl } : {}; + return this.ajax(`${this.urlForUpdateRecord(name, 'oidc/key')}/rotate`, 'POST', { data }); + } +} diff --git a/ui/app/adapters/oidc/provider.js b/ui/app/adapters/oidc/provider.js new file mode 100644 index 000000000..064e56968 --- /dev/null +++ b/ui/app/adapters/oidc/provider.js @@ -0,0 +1,7 @@ +import NamedPathAdapter from '../named-path'; + +export default class OidcProviderAdapter extends NamedPathAdapter { + pathForType() { + return 'identity/oidc/provider'; + } +} diff --git a/ui/app/adapters/oidc/scope.js b/ui/app/adapters/oidc/scope.js new file mode 100644 index 000000000..af69799d8 --- /dev/null +++ b/ui/app/adapters/oidc/scope.js @@ -0,0 +1,7 @@ +import NamedPathAdapter from '../named-path'; + +export default class OidcScopeAdapter extends NamedPathAdapter { + pathForType() { + return 'identity/oidc/scope'; + } +} diff --git a/ui/app/components/oidc/assignment-form.js b/ui/app/components/oidc/assignment-form.js new file mode 100644 index 000000000..d521bce93 --- /dev/null +++ b/ui/app/components/oidc/assignment-form.js @@ -0,0 +1,72 @@ +import Component from '@glimmer/component'; +import { action } from '@ember/object'; +import { inject as service } from '@ember/service'; +import { task } from 'ember-concurrency'; +import { tracked } from '@glimmer/tracking'; + +/** + * @module Oidc::AssignmentForm + * Oidc::AssignmentForm components are used to display the create view for OIDC providers assignments. + * + * @example + * ```js + * + * ``` + * @callback onCancel + * @callback onSave + * @param {object} model - The parent's model + * @param {string} onCancel - callback triggered when cancel button is clicked + * @param {string} onSave - callback triggered when save button is clicked + */ + +export default class OidcAssignmentFormComponent extends Component { + @service store; + @service flashMessages; + @tracked modelValidations; + @tracked errorBanner; + + @task + *save(event) { + event.preventDefault(); + try { + const { isValid, state } = this.args.model.validate(); + this.modelValidations = isValid ? null : state; + if (isValid) { + const { isNew, name } = this.args.model; + yield this.args.model.save(); + this.flashMessages.success(`Successfully ${isNew ? 'created' : 'updated'} the assignment ${name}.`); + // this form is sometimes used in modal, passing the model notifies + // the parent if the save was successful + this.args.onSave(this.args.model); + } + } catch (error) { + const message = error.errors ? error.errors.join('. ') : error.message; + this.errorBanner = message; + } + } + + @action + cancel() { + const method = this.args.model.isNew ? 'unloadRecord' : 'rollbackAttributes'; + this.args.model[method](); + this.args.onCancel(); + } + + @action + handleOperation({ target }) { + this.args.model.name = target.value; + } + + @action + onEntitiesSelect(selectedIds) { + this.args.model.entityIds = selectedIds; + } + + @action + onGroupsSelect(selectedIds) { + this.args.model.groupIds = selectedIds; + } +} diff --git a/ui/app/components/oidc/client-form.js b/ui/app/components/oidc/client-form.js new file mode 100644 index 000000000..2de6cf92a --- /dev/null +++ b/ui/app/components/oidc/client-form.js @@ -0,0 +1,91 @@ +import Component from '@glimmer/component'; +import { action } from '@ember/object'; +import { inject as service } from '@ember/service'; +import { tracked } from '@glimmer/tracking'; +import { task } from 'ember-concurrency'; +/** + * @module OidcClientForm + * OidcClientForm components are used to create and update OIDC clients (a.k.a. applications) + * + * @example + * ```js + * + * ``` + * @callback onCancel + * @callback onSave + * @param {Object} model - oidc client model + * @param {onCancel} onCancel - callback triggered when cancel button is clicked + * @param {onSave} onSave - callback triggered on save success + */ + +export default class OidcClientForm extends Component { + @service store; + @service flashMessages; + @tracked modelValidations; + @tracked errorBanner; + @tracked invalidFormAlert; + @tracked radioCardGroupValue = + !this.args.model.assignments || this.args.model.assignments.includes('allow_all') + ? 'allow_all' + : 'limited'; + + get modelAssignments() { + const { assignments } = this.args.model; + if (assignments.includes('allow_all') && assignments.length === 1) { + return []; + } else { + return assignments; + } + } + + @action + handleAssignmentSelection(selection) { + // if array then coming from search-select component, set selection as model assignments + if (Array.isArray(selection)) { + this.args.model.assignments = selection; + } else { + // otherwise update radio button value and reset assignments so + // UI always reflects a user's selection (including when no assignments are selected) + this.radioCardGroupValue = selection; + this.args.model.assignments = []; + } + } + + @action + cancel() { + const method = this.args.model.isNew ? 'unloadRecord' : 'rollbackAttributes'; + this.args.model[method](); + this.args.onCancel(); + } + + @task + *save(event) { + event.preventDefault(); + try { + const { isValid, state, invalidFormMessage } = this.args.model.validate(); + this.modelValidations = isValid ? null : state; + this.invalidFormAlert = invalidFormMessage; + if (isValid) { + if (this.radioCardGroupValue === 'allow_all') { + // the backend permits 'allow_all' AND other assignments, though 'allow_all' will take precedence + // the UI limits the config by allowing either 'allow_all' OR a list of other assignments + // note: when editing the UI removes any additional assignments previously configured via CLI + this.args.model.assignments = ['allow_all']; + } + // if TTL components are toggled off, set to default lease duration + const { idTokenTtl, accessTokenTtl } = this.args.model; + // value returned from API is a number, and string when from form action + if (Number(idTokenTtl) === 0) this.args.model.idTokenTtl = '24h'; + if (Number(accessTokenTtl) === 0) this.args.model.accessTokenTtl = '24h'; + const { isNew, name } = this.args.model; + yield this.args.model.save(); + this.flashMessages.success(`Successfully ${isNew ? 'created' : 'updated'} the application ${name}.`); + this.args.onSave(); + } + } 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/key-form.js b/ui/app/components/oidc/key-form.js new file mode 100644 index 000000000..3fe518cf0 --- /dev/null +++ b/ui/app/components/oidc/key-form.js @@ -0,0 +1,89 @@ +import Component from '@glimmer/component'; +import { action } from '@ember/object'; +import { inject as service } from '@ember/service'; +import { tracked } from '@glimmer/tracking'; +import { task } from 'ember-concurrency'; + +/** + * @module OidcKeyForm + * OidcKeyForm components are used to create and update OIDC providers + * + * @example + * ```js + * + * ``` + * @callback onCancel + * @callback onSave + * @param {Object} model - oidc client model + * @param {onCancel} onCancel - callback triggered when cancel button is clicked + * @param {onSave} onSave - callback triggered on save success + */ + +export default class OidcKeyForm extends Component { + @service store; + @service flashMessages; + @tracked errorBanner; + @tracked invalidFormAlert; + @tracked modelValidations; + @tracked radioCardGroupValue = + // If "*" is provided, all clients are allowed: https://www.vaultproject.io/api-docs/secret/identity/oidc-provider#parameters + !this.args.model.allowedClientIds || this.args.model.allowedClientIds.includes('*') + ? 'allow_all' + : 'limited'; + + 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] }; + } + + @action + handleClientSelection(selection) { + // if array then coming from search-select component, set selection as model clients + if (Array.isArray(selection)) { + this.args.model.allowedClientIds = selection.map((client) => client.clientId); + } 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 = []; + } + } + + @action + cancel() { + const method = this.args.model.isNew ? 'unloadRecord' : 'rollbackAttributes'; + this.args.model[method](); + this.args.onCancel(); + } + + @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 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.args.onSave(); + } + } 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 new file mode 100644 index 000000000..c51754da8 --- /dev/null +++ b/ui/app/components/oidc/provider-form.js @@ -0,0 +1,85 @@ +import Component from '@glimmer/component'; +import { action } from '@ember/object'; +import { inject as service } from '@ember/service'; +import { tracked } from '@glimmer/tracking'; +import { task } from 'ember-concurrency'; +import parseURL from 'core/utils/parse-url'; +/** + * @module OidcProviderForm + * OidcProviderForm components are used to create and update OIDC providers + * + * @example + * ```js + * + * ``` + * @callback onCancel + * @callback onSave + * @param {Object} model - oidc client model + * @param {onCancel} onCancel - callback triggered when cancel button is clicked + * @param {onSave} onSave - callback triggered on save success + */ + +export default class OidcProviderForm extends Component { + @service store; + @service flashMessages; + @tracked modelValidations; + @tracked errorBanner; + @tracked invalidFormAlert; + @tracked radioCardGroupValue = + // If "*" is provided, all clients are allowed: https://www.vaultproject.io/api-docs/secret/identity/oidc-provider#parameters + !this.args.model.allowedClientIds || this.args.model.allowedClientIds.includes('*') + ? 'allow_all' + : 'limited'; + + constructor() { + super(...arguments); + const { model } = this.args; + model.issuer = model.isNew ? '' : parseURL(model.issuer).origin; + } + + @action + handleClientSelection(selection) { + // if array then coming from search-select component, set selection as model clients + if (Array.isArray(selection)) { + this.args.model.allowedClientIds = selection.map((client) => client.clientId); + } 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 = []; + } + } + + @action + cancel() { + const method = this.args.model.isNew ? 'unloadRecord' : 'rollbackAttributes'; + this.args.model[method](); + this.args.onCancel(); + } + + @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 = ['*']; + } + yield this.args.model.save(); + this.flashMessages.success( + `Successfully ${isNew ? 'created' : 'updated'} the OIDC provider + ${name}.` + ); + this.args.onSave(); + } + } 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/scope-form.js b/ui/app/components/oidc/scope-form.js new file mode 100644 index 000000000..0e1c3a939 --- /dev/null +++ b/ui/app/components/oidc/scope-form.js @@ -0,0 +1,62 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { action } from '@ember/object'; +import { task } from 'ember-concurrency'; +import { inject as service } from '@ember/service'; + +/** + * @module OidcScopeForm + * Oidc scope form components are used to create and edit oidc scopes + * + * @example + * ```js + * + * ``` + * @callback onCancel + * @callback onSave + * @param {Object} model - oidc scope model + * @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 flashMessages; + @tracked errorBanner; + @tracked invalidFormAlert; + @tracked modelValidations; + // formatting here is purposeful so that whitespace renders correctly in JsonEditor + exampleTemplate = `{ + "username": {{identity.entity.aliases.$MOUNT_ACCESSOR.name}}, + "contact": { + "email": {{identity.entity.metadata.email}}, + "phone_number": {{identity.entity.metadata.phone_number}} + }, + "groups": {{identity.entity.groups.names}} +}`; + + @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(); + } + } 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/components/regex-validator.hbs b/ui/app/components/regex-validator.hbs index 10b3a36af..6576703c0 100644 --- a/ui/app/components/regex-validator.hbs +++ b/ui/app/components/regex-validator.hbs @@ -16,9 +16,9 @@

{{@attr.options.subText}} {{#if @attr.options.docLink}} - + See our documentation - + for help. {{/if}}

diff --git a/ui/app/controllers/vault/cluster/access/oidc.js b/ui/app/controllers/vault/cluster/access/oidc.js new file mode 100644 index 000000000..c6ef6dd70 --- /dev/null +++ b/ui/app/controllers/vault/cluster/access/oidc.js @@ -0,0 +1,34 @@ +import Controller from '@ember/controller'; +import { inject as service } from '@ember/service'; +import { tracked } from '@glimmer/tracking'; + +export default class OidcConfigureController extends Controller { + @service router; + + @tracked header = null; + + constructor() { + super(...arguments); + this.router.on('routeDidChange', (transition) => this.setHeader(transition)); + } + + setHeader(transition) { + // set correct header state based on child route + // when no clients have been created, display create button as call to action + // list views share the same header with tabs as resource links + // the remaining routes are responsible for their own header + const routeName = transition.to.name; + if (routeName.includes('oidc.index')) { + this.header = 'cta'; + } else { + const isList = ['clients', 'assignments', 'keys', 'scopes', 'providers'].find((resource) => { + return routeName.includes(`${resource}.index`); + }); + this.header = isList ? 'list' : null; + } + } + + get isCta() { + return this.header === 'cta'; + } +} diff --git a/ui/app/controllers/vault/cluster/access/oidc/assignments/assignment/details.js b/ui/app/controllers/vault/cluster/access/oidc/assignments/assignment/details.js new file mode 100644 index 000000000..bf21dc72f --- /dev/null +++ b/ui/app/controllers/vault/cluster/access/oidc/assignments/assignment/details.js @@ -0,0 +1,21 @@ +import Controller from '@ember/controller'; +import { action } from '@ember/object'; +import { inject as service } from '@ember/service'; + +export default class OidcAssignmentDetailsController extends Controller { + @service router; + @service flashMessages; + + @action + async delete() { + try { + await this.model.destroyRecord(); + this.flashMessages.success('Assignment deleted successfully'); + this.router.transitionTo('vault.cluster.access.oidc.assignments'); + } catch (error) { + this.model.rollbackAttributes(); + const message = error.errors ? error.errors.join('. ') : error.message; + this.flashMessages.danger(message); + } + } +} diff --git a/ui/app/controllers/vault/cluster/access/oidc/clients/client.js b/ui/app/controllers/vault/cluster/access/oidc/clients/client.js new file mode 100644 index 000000000..4e3e0c148 --- /dev/null +++ b/ui/app/controllers/vault/cluster/access/oidc/clients/client.js @@ -0,0 +1,21 @@ +import Controller from '@ember/controller'; +import { inject as service } from '@ember/service'; +import { tracked } from '@glimmer/tracking'; + +export default class OidcClientController extends Controller { + @service router; + @tracked isEditRoute; + + constructor() { + super(...arguments); + this.router.on( + 'routeDidChange', + ({ targetName }) => (this.isEditRoute = targetName.includes('edit') ? true : false) + ); + } + + get showHeader() { + // hide header when rendering the edit form + return !this.isEditRoute; + } +} diff --git a/ui/app/controllers/vault/cluster/access/oidc/clients/client/details.js b/ui/app/controllers/vault/cluster/access/oidc/clients/client/details.js new file mode 100644 index 000000000..55a8b296e --- /dev/null +++ b/ui/app/controllers/vault/cluster/access/oidc/clients/client/details.js @@ -0,0 +1,21 @@ +import Controller from '@ember/controller'; +import { action } from '@ember/object'; +import { inject as service } from '@ember/service'; + +export default class OidcClientDetailsController extends Controller { + @service router; + @service flashMessages; + + @action + async delete() { + try { + await this.model.destroyRecord(); + this.flashMessages.success('Application deleted successfully'); + this.router.transitionTo('vault.cluster.access.oidc.clients'); + } catch (error) { + this.model.rollbackAttributes(); + const message = error.errors ? error.errors.join('. ') : error.message; + this.flashMessages.danger(message); + } + } +} diff --git a/ui/app/controllers/vault/cluster/access/oidc/keys/key.js b/ui/app/controllers/vault/cluster/access/oidc/keys/key.js new file mode 100644 index 000000000..1658fda3a --- /dev/null +++ b/ui/app/controllers/vault/cluster/access/oidc/keys/key.js @@ -0,0 +1,20 @@ +import Controller from '@ember/controller'; +import { inject as service } from '@ember/service'; +import { tracked } from '@glimmer/tracking'; + +export default class OidcKeyController extends Controller { + @service router; + @tracked isEditRoute; + + constructor() { + super(...arguments); + this.router.on('routeDidChange', ({ targetName }) => { + return (this.isEditRoute = targetName.includes('edit') ? true : false); + }); + } + + get showHeader() { + // hide header when rendering the edit form + return !this.isEditRoute; + } +} 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 new file mode 100644 index 000000000..de844e1d7 --- /dev/null +++ b/ui/app/controllers/vault/cluster/access/oidc/keys/key/details.js @@ -0,0 +1,36 @@ +import Controller from '@ember/controller'; +import { action } from '@ember/object'; +import { inject as service } from '@ember/service'; +import { task } from 'ember-concurrency'; +import { waitFor } from '@ember/test-waiters'; + +export default class OidcKeyDetailsController extends Controller { + @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); + }); + } + @action + async delete() { + try { + await this.model.destroyRecord(); + 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; + this.flashMessages.danger(message); + } + } +} diff --git a/ui/app/controllers/vault/cluster/access/oidc/providers/provider.js b/ui/app/controllers/vault/cluster/access/oidc/providers/provider.js new file mode 100644 index 000000000..a0b1b295b --- /dev/null +++ b/ui/app/controllers/vault/cluster/access/oidc/providers/provider.js @@ -0,0 +1,20 @@ +import Controller from '@ember/controller'; +import { inject as service } from '@ember/service'; +import { tracked } from '@glimmer/tracking'; + +export default class OidcProviderController extends Controller { + @service router; + @tracked isEditRoute; + + constructor() { + super(...arguments); + this.router.on('routeDidChange', ({ targetName }) => { + return (this.isEditRoute = targetName.includes('edit') ? true : false); + }); + } + + get showHeader() { + // hide header when rendering the edit form + return !this.isEditRoute; + } +} diff --git a/ui/app/controllers/vault/cluster/access/oidc/providers/provider/details.js b/ui/app/controllers/vault/cluster/access/oidc/providers/provider/details.js new file mode 100644 index 000000000..6a15ef2e3 --- /dev/null +++ b/ui/app/controllers/vault/cluster/access/oidc/providers/provider/details.js @@ -0,0 +1,21 @@ +import Controller from '@ember/controller'; +import { action } from '@ember/object'; +import { inject as service } from '@ember/service'; + +export default class OidcProviderDetailsController extends Controller { + @service router; + @service flashMessages; + + @action + async delete() { + try { + await this.model.destroyRecord(); + this.flashMessages.success('Provider deleted successfully'); + this.router.transitionTo('vault.cluster.access.oidc.providers'); + } catch (error) { + this.model.rollbackAttributes(); + const message = error.errors ? error.errors.join('. ') : error.message; + this.flashMessages.danger(message); + } + } +} 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 new file mode 100644 index 000000000..1934ae082 --- /dev/null +++ b/ui/app/controllers/vault/cluster/access/oidc/scopes/scope/details.js @@ -0,0 +1,21 @@ +import Controller from '@ember/controller'; +import { action } from '@ember/object'; +import { inject as service } from '@ember/service'; + +export default class OidcScopeDetailsController extends Controller { + @service router; + @service flashMessages; + + @action + async delete() { + try { + await this.model.destroyRecord(); + 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; + this.flashMessages.danger(message); + } + } +} diff --git a/ui/app/decorators/model-validations.js b/ui/app/decorators/model-validations.js index 8520811da..1db84446d 100644 --- a/ui/app/decorators/model-validations.js +++ b/ui/app/decorators/model-validations.js @@ -22,7 +22,7 @@ import { get } from '@ember/object'; * state represents the error state of the properties defined in the validations object * const { isValid, errors } = state[propertyKeyName]; * isValid represents the validity of the property - * errors will be populated with messages defined in the validations object when validations fail + * errors will be populated with messages defined in the validations object when validations fail. message must be a complete sentence (and include punctuation) * since a property can have multiple validations, errors is always returned as an array * *** basic example @@ -30,7 +30,8 @@ import { get } from '@ember/object'; * import Model from '@ember-data/model'; * import withModelValidations from 'vault/decorators/model-validations'; * - * const validations = { foo: [{ type: 'presence', message: 'foo is a required field' }] }; + * Notes: all messages need to have a period at the end of them. + * const validations = { foo: [{ type: 'presence', message: 'foo is a required field.' }] }; * @withModelValidations(validations) * class SomeModel extends Model { foo = null; } * @@ -42,7 +43,7 @@ import { get } from '@ember/object'; * *** example using custom validator * - * const validations = { foo: [{ validator: (model) => model.bar.includes('test') ? model.foo : false, message: 'foo is required if bar includes test' }] }; + * const validations = { foo: [{ validator: (model) => model.bar.includes('test') ? model.foo : false, message: 'foo is required if bar includes test.' }] }; * @withModelValidations(validations) * class SomeModel extends Model { foo = false; bar = ['foo', 'baz']; } * @@ -50,7 +51,11 @@ import { get } from '@ember/object'; * const { isValid, state } = model.validate(); * -> isValid = false; * -> state.foo.isValid = false; - * -> state.foo.errors = ['foo is required if bar includes test']; + * -> state.foo.errors = ['foo is required if bar includes test.']; + * + * *** example adding class in hbs file + * all form-validations need to have a red border around them. Add this by adding a conditional class 'has-error-border' + * class="input field {{if this.errors.name.errors 'has-error-border'}}" */ export function withModelValidations(validations) { diff --git a/ui/app/models/database/connection.js b/ui/app/models/database/connection.js index d1fc76290..143882dca 100644 --- a/ui/app/models/database/connection.js +++ b/ui/app/models/database/connection.js @@ -54,7 +54,7 @@ export default Model.extend({ defaultSubText: 'Unless a custom policy is specified, Vault will use a default: 20 characters with at least 1 uppercase, 1 lowercase, 1 number, and 1 dash character.', defaultShown: 'Default', - docLink: 'https://www.vaultproject.io/docs/concepts/password-policies', + docLink: '/docs/concepts/password-policies', }), // common fields @@ -106,7 +106,7 @@ export default Model.extend({ subText: 'Enter the custom username template to use.', defaultSubText: 'Template describing how dynamic usernames are generated. Vault will use the default for this plugin.', - docLink: 'https://www.vaultproject.io/docs/concepts/username-templating', + docLink: '/docs/concepts/username-templating', defaultShown: 'Default', }), max_open_connections: attr('number', { diff --git a/ui/app/models/oidc/assignment.js b/ui/app/models/oidc/assignment.js new file mode 100644 index 000000000..980c5f62d --- /dev/null +++ b/ui/app/models/oidc/assignment.js @@ -0,0 +1,59 @@ +import Model, { attr } from '@ember-data/model'; +import lazyCapabilities, { apiPath } from 'vault/macros/lazy-capabilities'; +import { withModelValidations } from 'vault/decorators/model-validations'; +import { isPresent } from '@ember/utils'; + +const validations = { + name: [ + { type: 'presence', message: 'Name is required.' }, + { + type: 'containsWhiteSpace', + message: 'Name cannot contain whitespace.', + }, + ], + targets: [ + { + validator(model) { + return isPresent(model.entityIds) || isPresent(model.groupIds); + }, + message: 'At least one entity or group is required.', + }, + ], +}; + +@withModelValidations(validations) +export default class OidcAssignmentModel extends Model { + @attr('string') name; + @attr('array') entityIds; + @attr('array') groupIds; + + // CAPABILITIES + @lazyCapabilities(apiPath`identity/oidc/assignment/${'name'}`, 'name') assignmentPath; + @lazyCapabilities(apiPath`identity/oidc/assignment`) assignmentsPath; + + get canCreate() { + return this.assignmentPath.get('canCreate'); + } + get canRead() { + return this.assignmentPath.get('canRead'); + } + get canEdit() { + return this.assignmentPath.get('canUpdate'); + } + get canDelete() { + return this.assignmentPath.get('canDelete'); + } + get canList() { + return this.assignmentsPath.get('canList'); + } + + @lazyCapabilities(apiPath`identity/entity`) entitiesPath; + get canListEntities() { + return this.entitiesPath.get('canList'); + } + + @lazyCapabilities(apiPath`identity/group`) groupsPath; + get canListGroups() { + return this.groupsPath.get('canList'); + } +} diff --git a/ui/app/models/oidc/client.js b/ui/app/models/oidc/client.js new file mode 100644 index 000000000..89edafdcd --- /dev/null +++ b/ui/app/models/oidc/client.js @@ -0,0 +1,134 @@ +import Model, { attr } from '@ember-data/model'; +import lazyCapabilities, { apiPath } from 'vault/macros/lazy-capabilities'; +import { expandAttributeMeta } from 'vault/utils/field-to-attrs'; +import fieldToAttrs from 'vault/utils/field-to-attrs'; +import { withModelValidations } from 'vault/decorators/model-validations'; + +const validations = { + name: [ + { type: 'presence', message: 'Name is required.' }, + { + type: 'containsWhiteSpace', + message: 'Name cannot contain whitespace.', + }, + ], + key: [{ type: 'presence', message: 'Key is required.' }], +}; + +@withModelValidations(validations) +export default class OidcClientModel extends Model { + @attr('string', { label: 'Application name', editDisabled: true }) name; + @attr('string', { + label: 'Type', + subText: + 'Specify whether the application type is confidential or public. The public type must use PKCE. This cannot be edited later.', + editType: 'radio', + editDisabled: true, + defaultValue: 'confidential', + possibleValues: ['confidential', 'public'], + }) + clientType; + + @attr('array', { + label: 'Redirect URIs', + subText: + 'One of these values must exactly match the redirect_uri parameter value used in each authentication request.', + editType: 'stringArray', + }) + redirectUris; + + // >> MORE OPTIONS TOGGLE << + + @attr('string', { + label: 'Signing key', + subText: 'Add a key to sign and verify the JSON web tokens (JWT). This cannot be edited later.', + editType: 'searchSelect', + editDisabled: true, + onlyAllowExisting: true, + defaultValue() { + return ['default']; + }, + fallbackComponent: 'input-search', + selectLimit: 1, + models: ['oidc/key'], + }) + key; + @attr({ + label: 'Access Token TTL', + editType: 'ttl', + defaultValue: '24h', + }) + accessTokenTtl; + + @attr({ + label: 'ID Token TTL', + editType: 'ttl', + defaultValue: '24h', + }) + idTokenTtl; + + // >> END MORE OPTIONS TOGGLE << + + @attr('array', { label: 'Assign access' }) assignments; // no editType because does not use form-field component + @attr('string', { label: 'Client ID' }) clientId; + @attr('string') clientSecret; + + // CAPABILITIES // + @lazyCapabilities(apiPath`identity/oidc/client/${'name'}`, 'name') clientPath; + @lazyCapabilities(apiPath`identity/oidc/client`) clientsPath; + get canCreate() { + return this.clientPath.get('canCreate'); + } + get canRead() { + return this.clientPath.get('canRead'); + } + get canEdit() { + return this.clientPath.get('canUpdate'); + } + get canDelete() { + return this.clientPath.get('canDelete'); + } + get canList() { + return this.clientsPath.get('canList'); + } + + @lazyCapabilities(apiPath`identity/oidc/key`) keysPath; + get canListKeys() { + return this.keysPath.get('canList'); + } + + @lazyCapabilities(apiPath`identity/oidc/assignment/${'name'}`, 'name') assignmentPath; + @lazyCapabilities(apiPath`identity/oidc/assignment`) assignmentsPath; + get canCreateAssignments() { + return this.assignmentPath.get('canCreate'); + } + get canListAssignments() { + return this.assignmentsPath.get('canList'); + } + + // API WIP + @lazyCapabilities(apiPath`identity/oidc/${'name'}/provider`, 'backend', 'name') clientProvidersPath; + get canListProviders() { + return this.clientProvidersPath.get('canList'); + } + + // TODO refactor when field-to-attrs util is refactored as decorator + _attributeMeta = null; // cache initial result of expandAttributeMeta in getter and return + get formFields() { + if (!this._attributeMeta) { + this._attributeMeta = expandAttributeMeta(this, ['name', 'clientType', 'redirectUris']); + } + return this._attributeMeta; + } + + _fieldToAttrsGroups = null; + // more options fields + get fieldGroups() { + if (!this._fieldToAttrsGroups) { + this._fieldToAttrsGroups = fieldToAttrs(this, [ + { 'More options': ['key', 'idTokenTtl', 'accessTokenTtl'] }, + ]); + } + return this._fieldToAttrsGroups; + } +} diff --git a/ui/app/models/oidc/key.js b/ui/app/models/oidc/key.js new file mode 100644 index 000000000..843516d7e --- /dev/null +++ b/ui/app/models/oidc/key.js @@ -0,0 +1,64 @@ +import Model, { attr } from '@ember-data/model'; +import lazyCapabilities, { apiPath } from 'vault/macros/lazy-capabilities'; +import { expandAttributeMeta } from 'vault/utils/field-to-attrs'; +import { withModelValidations } from 'vault/decorators/model-validations'; + +const validations = { + name: [ + { type: 'presence', message: 'Name is required.' }, + { + type: 'containsWhiteSpace', + message: 'Name cannot contain whitespace.', + }, + ], +}; + +@withModelValidations(validations) +export default class OidcKeyModel extends Model { + @attr('string', { editDisabled: true }) name; + @attr('string', { + defaultValue: 'RS256', + possibleValues: ['RS256', 'RS384', 'RS512', 'ES256', 'ES384', 'ES512', 'EdDSA'], + }) + algorithm; + + @attr({ editType: 'ttl', defaultValue: '24h' }) rotationPeriod; + @attr({ label: 'Verification TTL', editType: 'ttl', defaultValue: '24h' }) verificationTtl; + @attr('array', { label: 'Allowed applications' }) allowedClientIds; // no editType because does not use form-field component + + // TODO refactor when field-to-attrs is refactored as decorator + _attributeMeta = null; // cache initial result of expandAttributeMeta in getter and return + get formFields() { + if (!this._attributeMeta) { + this._attributeMeta = expandAttributeMeta(this, [ + 'name', + 'algorithm', + 'rotationPeriod', + 'verificationTtl', + ]); + } + return this._attributeMeta; + } + + @lazyCapabilities(apiPath`identity/oidc/key/${'name'}`, 'name') keyPath; + @lazyCapabilities(apiPath`identity/oidc/key/${'name'}/rotate`, 'name') rotatePath; + @lazyCapabilities(apiPath`identity/oidc/key`) keysPath; + get canCreate() { + return this.keyPath.get('canCreate'); + } + get canRead() { + return this.keyPath.get('canRead'); + } + get canEdit() { + return this.keyPath.get('canUpdate'); + } + get canRotate() { + return this.rotatePath.get('canUpdate'); + } + get canDelete() { + return this.keyPath.get('canDelete'); + } + get canList() { + return this.keysPath.get('canList'); + } +} diff --git a/ui/app/models/oidc/provider.js b/ui/app/models/oidc/provider.js new file mode 100644 index 000000000..f87efa900 --- /dev/null +++ b/ui/app/models/oidc/provider.js @@ -0,0 +1,74 @@ +import Model, { attr } from '@ember-data/model'; +import lazyCapabilities, { apiPath } from 'vault/macros/lazy-capabilities'; +import { expandAttributeMeta } from 'vault/utils/field-to-attrs'; +import { withModelValidations } from 'vault/decorators/model-validations'; + +const validations = { + name: [ + { type: 'presence', message: 'Name is required.' }, + { + type: 'containsWhiteSpace', + message: 'Name cannot contain whitespace.', + }, + ], +}; + +@withModelValidations(validations) +export default class OidcProviderModel extends Model { + @attr('string', { editDisabled: true }) name; + @attr('string', { + subText: + 'The scheme, host, and optional port for your issuer. This will be used to build the URL that validates ID tokens.', + placeholderText: 'e.g. https://example.com:8200', + docLink: '/api-docs/secret/identity/oidc-provider#create-or-update-a-provider', + helpText: `Optional. This defaults to a URL with Vault's api_addr`, + }) + issuer; + + @attr('array', { + label: 'Supported scopes', + subText: 'Scopes define information about a user and the OIDC service. Optional.', + editType: 'searchSelect', + models: ['oidc/scope'], + fallbackComponent: 'string-list', + onlyAllowExisting: true, + }) + scopesSupported; + + @attr('array', { label: 'Allowed applications' }) allowedClientIds; // no editType because does not use form-field component + + // TODO refactor when field-to-attrs is refactored as decorator + _attributeMeta = null; // cache initial result of expandAttributeMeta in getter and return + get formFields() { + if (!this._attributeMeta) { + this._attributeMeta = expandAttributeMeta(this, ['name', 'issuer', 'scopesSupported']); + } + return this._attributeMeta; + } + @lazyCapabilities(apiPath`identity/oidc/provider/${'name'}`, 'name') providerPath; + @lazyCapabilities(apiPath`identity/oidc/provider`) providersPath; + get canCreate() { + return this.providerPath.get('canCreate'); + } + get canRead() { + return this.providerPath.get('canRead'); + } + get canEdit() { + return this.providerPath.get('canUpdate'); + } + get canDelete() { + return this.providerPath.get('canDelete'); + } + get canList() { + return this.providersPath.get('canList'); + } + + @lazyCapabilities(apiPath`identity/oidc/client`) clientsPath; + get canListClients() { + return this.clientsPath.get('canList'); + } + @lazyCapabilities(apiPath`identity/oidc/scope`) scopesPath; + get canListScopes() { + return this.scopesPath.get('canList'); + } +} diff --git a/ui/app/models/oidc/scope.js b/ui/app/models/oidc/scope.js new file mode 100644 index 000000000..aed338e68 --- /dev/null +++ b/ui/app/models/oidc/scope.js @@ -0,0 +1,42 @@ +import Model, { attr } from '@ember-data/model'; +import lazyCapabilities, { apiPath } from 'vault/macros/lazy-capabilities'; +import { expandAttributeMeta } from 'vault/utils/field-to-attrs'; +import { withModelValidations } from 'vault/decorators/model-validations'; + +const validations = { + name: [{ type: 'presence', message: 'Name is required.' }], +}; + +@withModelValidations(validations) +export default class OidcScopeModel extends Model { + @attr('string', { editDisabled: true }) name; + @attr('string', { editType: 'textarea' }) description; + @attr('string', { label: 'JSON Template', editType: 'json', mode: 'ruby' }) template; + + // TODO refactor when field-to-attrs is refactored as decorator + _attributeMeta = null; // cache initial result of expandAttributeMeta in getter and return + get formFields() { + if (!this._attributeMeta) { + this._attributeMeta = expandAttributeMeta(this, ['name', 'description', 'template']); + } + return this._attributeMeta; + } + + @lazyCapabilities(apiPath`identity/oidc/scope/${'name'}`, 'name') scopePath; + @lazyCapabilities(apiPath`identity/oidc/scope`) scopesPath; + get canCreate() { + return this.scopePath.get('canCreate'); + } + get canRead() { + return this.scopePath.get('canRead'); + } + get canEdit() { + return this.scopePath.get('canUpdate'); + } + get canDelete() { + return this.scopePath.get('canDelete'); + } + get canList() { + return this.scopesPath.get('canList'); + } +} diff --git a/ui/app/router.js b/ui/app/router.js index e59a5c6d7..458869121 100644 --- a/ui/app/router.js +++ b/ui/app/router.js @@ -109,6 +109,46 @@ Router.map(function () { this.route('index', { path: '/' }); this.route('create'); }); + this.route('oidc', function () { + this.route('clients', function () { + this.route('create'); + this.route('client', { path: '/:name' }, function () { + this.route('details'); + this.route('providers'); + this.route('edit'); + }); + }); + this.route('keys', function () { + this.route('create'); + this.route('key', { path: '/:name' }, function () { + this.route('details'); + this.route('clients'); + this.route('edit'); + }); + }); + this.route('assignments', function () { + this.route('create'); + this.route('assignment', { path: '/:name' }, function () { + this.route('details'); + this.route('edit'); + }); + }); + this.route('providers', function () { + this.route('create'); + this.route('provider', { path: '/:name' }, function () { + this.route('details'); + this.route('clients'); + this.route('edit'); + }); + }); + this.route('scopes', function () { + this.route('create'); + this.route('scope', { path: '/:name' }, function () { + this.route('details'); + this.route('edit'); + }); + }); + }); }); this.route('secrets', function () { this.route('backends', { path: '/' }); diff --git a/ui/app/routes/vault/cluster/access/oidc.js b/ui/app/routes/vault/cluster/access/oidc.js new file mode 100644 index 000000000..18903d49e --- /dev/null +++ b/ui/app/routes/vault/cluster/access/oidc.js @@ -0,0 +1,3 @@ +import Route from '@ember/routing/route'; + +export default class OidcConfigureRoute extends Route {} diff --git a/ui/app/routes/vault/cluster/access/oidc/assignments/assignment.js b/ui/app/routes/vault/cluster/access/oidc/assignments/assignment.js new file mode 100644 index 000000000..54011c1df --- /dev/null +++ b/ui/app/routes/vault/cluster/access/oidc/assignments/assignment.js @@ -0,0 +1,7 @@ +import Route from '@ember/routing/route'; + +export default class OidcAssignmentRoute extends Route { + model({ name }) { + return this.store.findRecord('oidc/assignment', name); + } +} diff --git a/ui/app/routes/vault/cluster/access/oidc/assignments/assignment/details.js b/ui/app/routes/vault/cluster/access/oidc/assignments/assignment/details.js new file mode 100644 index 000000000..bdc58a723 --- /dev/null +++ b/ui/app/routes/vault/cluster/access/oidc/assignments/assignment/details.js @@ -0,0 +1,3 @@ +import Route from '@ember/routing/route'; + +export default class OidcAssignmentDetailsRoute extends Route {} diff --git a/ui/app/routes/vault/cluster/access/oidc/assignments/assignment/edit.js b/ui/app/routes/vault/cluster/access/oidc/assignments/assignment/edit.js new file mode 100644 index 000000000..e4dd299a2 --- /dev/null +++ b/ui/app/routes/vault/cluster/access/oidc/assignments/assignment/edit.js @@ -0,0 +1,3 @@ +import Route from '@ember/routing/route'; + +export default class OidcAssignmentEditRoute extends Route {} diff --git a/ui/app/routes/vault/cluster/access/oidc/assignments/create.js b/ui/app/routes/vault/cluster/access/oidc/assignments/create.js new file mode 100644 index 000000000..414ae467b --- /dev/null +++ b/ui/app/routes/vault/cluster/access/oidc/assignments/create.js @@ -0,0 +1,7 @@ +import Route from '@ember/routing/route'; + +export default class OidcAssignmentsCreateRoute extends Route { + model() { + return this.store.createRecord('oidc/assignment'); + } +} diff --git a/ui/app/routes/vault/cluster/access/oidc/assignments/index.js b/ui/app/routes/vault/cluster/access/oidc/assignments/index.js new file mode 100644 index 000000000..3956673c5 --- /dev/null +++ b/ui/app/routes/vault/cluster/access/oidc/assignments/index.js @@ -0,0 +1,13 @@ +import Route from '@ember/routing/route'; + +export default class OidcAssignmentsRoute extends Route { + model() { + return this.store.query('oidc/assignment', {}).catch((err) => { + if (err.httpStatus === 404) { + return []; + } else { + throw err; + } + }); + } +} diff --git a/ui/app/routes/vault/cluster/access/oidc/clients/client.js b/ui/app/routes/vault/cluster/access/oidc/clients/client.js new file mode 100644 index 000000000..f2363a6e3 --- /dev/null +++ b/ui/app/routes/vault/cluster/access/oidc/clients/client.js @@ -0,0 +1,7 @@ +import Route from '@ember/routing/route'; + +export default class OidcClientRoute extends Route { + model({ name }) { + return this.store.findRecord('oidc/client', name); + } +} diff --git a/ui/app/routes/vault/cluster/access/oidc/clients/client/details.js b/ui/app/routes/vault/cluster/access/oidc/clients/client/details.js new file mode 100644 index 000000000..db38b6058 --- /dev/null +++ b/ui/app/routes/vault/cluster/access/oidc/clients/client/details.js @@ -0,0 +1,2 @@ +import Route from '@ember/routing/route'; +export default class OidcClientDetailsRoute extends Route {} diff --git a/ui/app/routes/vault/cluster/access/oidc/clients/client/edit.js b/ui/app/routes/vault/cluster/access/oidc/clients/client/edit.js new file mode 100644 index 000000000..fd4e7a5bb --- /dev/null +++ b/ui/app/routes/vault/cluster/access/oidc/clients/client/edit.js @@ -0,0 +1,3 @@ +import Route from '@ember/routing/route'; + +export default class OidcClientEditRoute extends Route {} diff --git a/ui/app/routes/vault/cluster/access/oidc/clients/client/providers.js b/ui/app/routes/vault/cluster/access/oidc/clients/client/providers.js new file mode 100644 index 000000000..653a50fab --- /dev/null +++ b/ui/app/routes/vault/cluster/access/oidc/clients/client/providers.js @@ -0,0 +1,18 @@ +import Route from '@ember/routing/route'; + +export default class OidcClientProvidersRoute extends Route { + model() { + const model = this.modelFor('vault.cluster.access.oidc.clients.client'); + return this.store + .query('oidc/provider', { + allowed_client_id: model.clientId, + }) + .catch((err) => { + if (err.httpStatus === 404) { + return []; + } else { + throw err; + } + }); + } +} diff --git a/ui/app/routes/vault/cluster/access/oidc/clients/create.js b/ui/app/routes/vault/cluster/access/oidc/clients/create.js new file mode 100644 index 000000000..ba8674471 --- /dev/null +++ b/ui/app/routes/vault/cluster/access/oidc/clients/create.js @@ -0,0 +1,7 @@ +import Route from '@ember/routing/route'; + +export default class OidcClientsCreateRoute extends Route { + model() { + return this.store.createRecord('oidc/client'); + } +} diff --git a/ui/app/routes/vault/cluster/access/oidc/clients/index.js b/ui/app/routes/vault/cluster/access/oidc/clients/index.js new file mode 100644 index 000000000..08ec2bef2 --- /dev/null +++ b/ui/app/routes/vault/cluster/access/oidc/clients/index.js @@ -0,0 +1,21 @@ +import Route from '@ember/routing/route'; +import { inject as service } from '@ember/service'; +export default class OidcClientsRoute extends Route { + @service router; + + model() { + return this.store.query('oidc/client', {}).catch((err) => { + if (err.httpStatus === 404) { + return []; + } else { + throw err; + } + }); + } + + afterModel(model) { + if (model.length === 0) { + this.router.transitionTo('vault.cluster.access.oidc'); + } + } +} diff --git a/ui/app/routes/vault/cluster/access/oidc/index.js b/ui/app/routes/vault/cluster/access/oidc/index.js new file mode 100644 index 000000000..f7262a7e9 --- /dev/null +++ b/ui/app/routes/vault/cluster/access/oidc/index.js @@ -0,0 +1,18 @@ +import Route from '@ember/routing/route'; +import { inject as service } from '@ember/service'; + +export default class OidcConfigureRoute extends Route { + @service router; + + beforeModel() { + return this.store + .query('oidc/client', {}) + .then(() => { + // transition to client list view if clients have been created + this.router.transitionTo('vault.cluster.access.oidc.clients'); + }) + .catch(() => { + // adapter throws error for 404 - swallow and remain on index route to show call to action + }); + } +} diff --git a/ui/app/routes/vault/cluster/access/oidc/keys/create.js b/ui/app/routes/vault/cluster/access/oidc/keys/create.js new file mode 100644 index 000000000..424e2806c --- /dev/null +++ b/ui/app/routes/vault/cluster/access/oidc/keys/create.js @@ -0,0 +1,7 @@ +import Route from '@ember/routing/route'; + +export default class OidcKeysCreateRoute extends Route { + model() { + return this.store.createRecord('oidc/key'); + } +} diff --git a/ui/app/routes/vault/cluster/access/oidc/keys/index.js b/ui/app/routes/vault/cluster/access/oidc/keys/index.js new file mode 100644 index 000000000..5e9b48be9 --- /dev/null +++ b/ui/app/routes/vault/cluster/access/oidc/keys/index.js @@ -0,0 +1,12 @@ +import Route from '@ember/routing/route'; +export default class OidcKeysRoute extends Route { + model() { + return this.store.query('oidc/key', {}).catch((err) => { + if (err.httpStatus === 404) { + return []; + } else { + throw err; + } + }); + } +} diff --git a/ui/app/routes/vault/cluster/access/oidc/keys/key.js b/ui/app/routes/vault/cluster/access/oidc/keys/key.js new file mode 100644 index 000000000..d537e62c3 --- /dev/null +++ b/ui/app/routes/vault/cluster/access/oidc/keys/key.js @@ -0,0 +1,7 @@ +import Route from '@ember/routing/route'; + +export default class OidcKeyRoute extends Route { + model({ name }) { + return this.store.findRecord('oidc/key', name); + } +} 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 new file mode 100644 index 000000000..a96aba0ae --- /dev/null +++ b/ui/app/routes/vault/cluster/access/oidc/keys/key/clients.js @@ -0,0 +1,8 @@ +import Route from '@ember/routing/route'; + +export default class OidcKeyClientsRoute extends Route { + async model() { + const { allowedClientIds } = this.modelFor('vault.cluster.access.oidc.keys.key'); + return await this.store.query('oidc/client', { paramKey: 'client_id', filterFor: allowedClientIds }); + } +} diff --git a/ui/app/routes/vault/cluster/access/oidc/keys/key/details.js b/ui/app/routes/vault/cluster/access/oidc/keys/key/details.js new file mode 100644 index 000000000..df05a168c --- /dev/null +++ b/ui/app/routes/vault/cluster/access/oidc/keys/key/details.js @@ -0,0 +1,3 @@ +import Route from '@ember/routing/route'; + +export default class OidcKeyDetailsRoute extends Route {} 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 new file mode 100644 index 000000000..c86f197c3 --- /dev/null +++ b/ui/app/routes/vault/cluster/access/oidc/keys/key/edit.js @@ -0,0 +1,3 @@ +import Route from '@ember/routing/route'; + +export default class OidcKeyEditRoute extends Route {} diff --git a/ui/app/routes/vault/cluster/access/oidc/providers/create.js b/ui/app/routes/vault/cluster/access/oidc/providers/create.js new file mode 100644 index 000000000..3a6b9667b --- /dev/null +++ b/ui/app/routes/vault/cluster/access/oidc/providers/create.js @@ -0,0 +1,7 @@ +import Route from '@ember/routing/route'; + +export default class OidcProvidersCreateRoute extends Route { + model() { + return this.store.createRecord('oidc/provider'); + } +} diff --git a/ui/app/routes/vault/cluster/access/oidc/providers/index.js b/ui/app/routes/vault/cluster/access/oidc/providers/index.js new file mode 100644 index 000000000..82250f19d --- /dev/null +++ b/ui/app/routes/vault/cluster/access/oidc/providers/index.js @@ -0,0 +1,13 @@ +import Route from '@ember/routing/route'; + +export default class OidcProvidersRoute extends Route { + model() { + return this.store.query('oidc/provider', {}).catch((err) => { + if (err.httpStatus === 404) { + return []; + } else { + throw err; + } + }); + } +} diff --git a/ui/app/routes/vault/cluster/access/oidc/providers/provider.js b/ui/app/routes/vault/cluster/access/oidc/providers/provider.js new file mode 100644 index 000000000..66a8fadaa --- /dev/null +++ b/ui/app/routes/vault/cluster/access/oidc/providers/provider.js @@ -0,0 +1,7 @@ +import Route from '@ember/routing/route'; + +export default class OidcProviderRoute extends Route { + model({ name }) { + return this.store.findRecord('oidc/provider', name); + } +} diff --git a/ui/app/routes/vault/cluster/access/oidc/providers/provider/clients.js b/ui/app/routes/vault/cluster/access/oidc/providers/provider/clients.js new file mode 100644 index 000000000..586a73c13 --- /dev/null +++ b/ui/app/routes/vault/cluster/access/oidc/providers/provider/clients.js @@ -0,0 +1,8 @@ +import Route from '@ember/routing/route'; + +export default class OidcProviderClientsRoute extends Route { + async model() { + const { allowedClientIds } = this.modelFor('vault.cluster.access.oidc.providers.provider'); + return await this.store.query('oidc/client', { paramKey: 'client_id', filterFor: allowedClientIds }); + } +} diff --git a/ui/app/routes/vault/cluster/access/oidc/providers/provider/details.js b/ui/app/routes/vault/cluster/access/oidc/providers/provider/details.js new file mode 100644 index 000000000..af4d1077d --- /dev/null +++ b/ui/app/routes/vault/cluster/access/oidc/providers/provider/details.js @@ -0,0 +1,3 @@ +import Route from '@ember/routing/route'; + +export default class OidcProviderDetailsRoute extends Route {} diff --git a/ui/app/routes/vault/cluster/access/oidc/providers/provider/edit.js b/ui/app/routes/vault/cluster/access/oidc/providers/provider/edit.js new file mode 100644 index 000000000..365b2328e --- /dev/null +++ b/ui/app/routes/vault/cluster/access/oidc/providers/provider/edit.js @@ -0,0 +1,3 @@ +import Route from '@ember/routing/route'; + +export default class OidcProviderEditRoute extends Route {} diff --git a/ui/app/routes/vault/cluster/access/oidc/scopes/create.js b/ui/app/routes/vault/cluster/access/oidc/scopes/create.js new file mode 100644 index 000000000..cd1862279 --- /dev/null +++ b/ui/app/routes/vault/cluster/access/oidc/scopes/create.js @@ -0,0 +1,7 @@ +import Route from '@ember/routing/route'; + +export default class OidcScopesCreateRoute extends Route { + model() { + return this.store.createRecord('oidc/scope'); + } +} diff --git a/ui/app/routes/vault/cluster/access/oidc/scopes/index.js b/ui/app/routes/vault/cluster/access/oidc/scopes/index.js new file mode 100644 index 000000000..e8108980a --- /dev/null +++ b/ui/app/routes/vault/cluster/access/oidc/scopes/index.js @@ -0,0 +1,13 @@ +import Route from '@ember/routing/route'; + +export default class OidcScopesRoute extends Route { + model() { + return this.store.query('oidc/scope', {}).catch((err) => { + if (err.httpStatus === 404) { + return []; + } else { + throw err; + } + }); + } +} diff --git a/ui/app/routes/vault/cluster/access/oidc/scopes/scope.js b/ui/app/routes/vault/cluster/access/oidc/scopes/scope.js new file mode 100644 index 000000000..7a4c5c08d --- /dev/null +++ b/ui/app/routes/vault/cluster/access/oidc/scopes/scope.js @@ -0,0 +1,7 @@ +import Route from '@ember/routing/route'; + +export default class OidcScopeRoute extends Route { + model({ name }) { + return this.store.findRecord('oidc/scope', name); + } +} diff --git a/ui/app/routes/vault/cluster/access/oidc/scopes/scope/details.js b/ui/app/routes/vault/cluster/access/oidc/scopes/scope/details.js new file mode 100644 index 000000000..6047afcfc --- /dev/null +++ b/ui/app/routes/vault/cluster/access/oidc/scopes/scope/details.js @@ -0,0 +1,3 @@ +import Route from '@ember/routing/route'; + +export default class OidcScopeDetailsRoute extends Route {} 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 new file mode 100644 index 000000000..fc8b61fc0 --- /dev/null +++ b/ui/app/routes/vault/cluster/access/oidc/scopes/scope/edit.js @@ -0,0 +1,3 @@ +import Route from '@ember/routing/route'; + +export default class OidcScopeEditRoute extends Route {} diff --git a/ui/app/serializers/oidc/assignment.js b/ui/app/serializers/oidc/assignment.js new file mode 100644 index 000000000..1a7693d6c --- /dev/null +++ b/ui/app/serializers/oidc/assignment.js @@ -0,0 +1,5 @@ +import ApplicationSerializer from '../application'; + +export default class OidcAssignmentSerializer extends ApplicationSerializer { + primaryKey = 'name'; +} diff --git a/ui/app/serializers/oidc/client.js b/ui/app/serializers/oidc/client.js new file mode 100644 index 000000000..784d56cc8 --- /dev/null +++ b/ui/app/serializers/oidc/client.js @@ -0,0 +1,17 @@ +import ApplicationSerializer from '../application'; + +export default class OidcClientSerializer extends ApplicationSerializer { + primaryKey = 'name'; + + // rehydrate each client model so all model attributes are accessible from the LIST response + normalizeItems(payload) { + if (payload.data) { + if (payload.data?.keys && Array.isArray(payload.data.keys)) { + return payload.data.keys.map((key) => ({ name: key, ...payload.data.key_info[key] })); + } + Object.assign(payload, payload.data); + delete payload.data; + } + return payload; + } +} diff --git a/ui/app/serializers/oidc/key.js b/ui/app/serializers/oidc/key.js new file mode 100644 index 000000000..bf2890f40 --- /dev/null +++ b/ui/app/serializers/oidc/key.js @@ -0,0 +1,5 @@ +import ApplicationSerializer from '../application'; + +export default class OidcKeySerializer extends ApplicationSerializer { + primaryKey = 'name'; +} diff --git a/ui/app/serializers/oidc/provider.js b/ui/app/serializers/oidc/provider.js new file mode 100644 index 000000000..da0b35e29 --- /dev/null +++ b/ui/app/serializers/oidc/provider.js @@ -0,0 +1,17 @@ +import ApplicationSerializer from '../application'; + +export default class OidcProviderSerializer extends ApplicationSerializer { + primaryKey = 'name'; + + // need to normalize to get issuer metadata for provider's list view + normalizeItems(payload) { + if (payload.data) { + if (payload.data?.keys && Array.isArray(payload.data.keys)) { + return payload.data.keys.map((key) => ({ name: key, ...payload.data.key_info[key] })); + } + Object.assign(payload, payload.data); + delete payload.data; + } + return payload; + } +} diff --git a/ui/app/serializers/oidc/scope.js b/ui/app/serializers/oidc/scope.js new file mode 100644 index 000000000..17c7c0202 --- /dev/null +++ b/ui/app/serializers/oidc/scope.js @@ -0,0 +1,5 @@ +import ApplicationSerializer from '../application'; + +export default class OidcScopeSerializer extends ApplicationSerializer { + primaryKey = 'name'; +} diff --git a/ui/app/services/permissions.js b/ui/app/services/permissions.js index 6364ab445..a8d50b953 100644 --- a/ui/app/services/permissions.js +++ b/ui/app/services/permissions.js @@ -5,6 +5,7 @@ const API_PATHS = { access: { methods: 'sys/auth', mfa: 'identity/mfa/method', + oidc: 'identity/oidc/client', entities: 'identity/entity/id', groups: 'identity/group/id', leases: 'sys/leases/lookup', @@ -44,6 +45,7 @@ const API_PATHS_TO_ROUTE_PARAMS = { 'sys/namespaces': { route: 'vault.cluster.access.namespaces', models: [] }, 'sys/control-group/': { route: 'vault.cluster.access.control-groups', models: [] }, 'identity/mfa/method': { route: 'vault.cluster.access.mfa', models: [] }, + 'identity/oidc/client': { route: 'vault.cluster.access.oidc', models: [] }, }; /* diff --git a/ui/app/styles/components/list-item-row.scss b/ui/app/styles/components/list-item-row.scss index 75cad84fb..9bdc702f3 100644 --- a/ui/app/styles/components/list-item-row.scss +++ b/ui/app/styles/components/list-item-row.scss @@ -22,6 +22,10 @@ margin-left: auto; margin-right: auto; } + + &.is-disabled { + opacity: 0.5; + } } a.list-item-row, diff --git a/ui/app/styles/components/radio-card.scss b/ui/app/styles/components/radio-card.scss index f65e21e6f..e98020eb6 100644 --- a/ui/app/styles/components/radio-card.scss +++ b/ui/app/styles/components/radio-card.scss @@ -3,16 +3,15 @@ margin-bottom: $spacing-xs; } .radio-card { - width: 19rem; box-shadow: $box-shadow-low; - display: flex; + flex: 1 1 25%; flex-direction: column; justify-content: space-between; margin: $spacing-xs $spacing-m; border: $base-border; border-radius: $radius; transition: all ease-in-out $speed; - + max-width: 60%; input[type='radio'] { position: absolute; z-index: 1; diff --git a/ui/app/styles/core/buttons.scss b/ui/app/styles/core/buttons.scss index 031ee33e9..9ae0a2e84 100644 --- a/ui/app/styles/core/buttons.scss +++ b/ui/app/styles/core/buttons.scss @@ -272,3 +272,13 @@ a.button.disabled { border: none; cursor: pointer; } +.text-button { + padding: unset; + border: none; + background-color: inherit; + color: inherit; + font-size: inherit; + font-weight: inherit; + cursor: pointer; + color: $link; +} diff --git a/ui/app/styles/core/forms.scss b/ui/app/styles/core/forms.scss index 55d4350df..ef41a3edd 100644 --- a/ui/app/styles/core/forms.scss +++ b/ui/app/styles/core/forms.scss @@ -331,6 +331,11 @@ select.has-error-border { border: 1px solid $red-500; } +.dropdown-has-error-border > div.ember-basic-dropdown-trigger { + border: 1px solid $red-500; +} + + .autocomplete-input { background: $white !important; border: 1px solid $grey-light; diff --git a/ui/app/styles/core/helpers.scss b/ui/app/styles/core/helpers.scss index 72c0423eb..88b741088 100644 --- a/ui/app/styles/core/helpers.scss +++ b/ui/app/styles/core/helpers.scss @@ -174,6 +174,9 @@ .has-top-padding-l { padding-top: $spacing-l; } +.has-top-padding-xxl { + padding-top: $spacing-xxl; +} .has-bottom-margin-xs { margin-bottom: $spacing-xs; } diff --git a/ui/app/templates/components/alphabet-edit.hbs b/ui/app/templates/components/alphabet-edit.hbs index 8d58eff1b..0e764c7ac 100644 --- a/ui/app/templates/components/alphabet-edit.hbs +++ b/ui/app/templates/components/alphabet-edit.hbs @@ -116,7 +116,6 @@ @value={{get this.model attr.name}} @type={{attr.type}} @isLink={{eq attr.name "transformations"}} - @viewAll="transformations" /> {{else}} {{attr.options.helpText}} {{#if attr.options.docLink}} - + See our documentation - + for help. {{/if}}

@@ -43,9 +43,9 @@

{{attr.options.subText}} {{#if attr.options.docLink}} - + See our documentation - + for help. {{/if}}

diff --git a/ui/app/templates/components/mfa/mfa-login-enforcement-form.hbs b/ui/app/templates/components/mfa/mfa-login-enforcement-form.hbs index cf9f9554b..7b8c51a0c 100644 --- a/ui/app/templates/components/mfa/mfa-login-enforcement-form.hbs +++ b/ui/app/templates/components/mfa/mfa-login-enforcement-form.hbs @@ -10,7 +10,7 @@ spellcheck="false" value={{@model.name}} disabled={{not @model.isNew}} - class="input field" + class="input field {{if this.errors.name.errors 'has-error-border'}}" data-test-mlef-input="name" {{on "input" (pipe (pick "target.value") (fn (mut @model.name)))}} /> diff --git a/ui/app/templates/components/oidc/assignment-form.hbs b/ui/app/templates/components/oidc/assignment-form.hbs new file mode 100644 index 000000000..5ec113187 --- /dev/null +++ b/ui/app/templates/components/oidc/assignment-form.hbs @@ -0,0 +1,95 @@ +{{#unless @isInline}} + + + + + +

+ {{if @model.isNew "Create" "Edit"}} + assignment +

+
+
+{{/unless}} +
+
+ + + + {{#if this.modelValidations.name.errors}} + + {{/if}} + + +
+
+ + + {{#if this.modelValidations.targets.errors}} + + {{/if}} +
+
\ No newline at end of file diff --git a/ui/app/templates/components/oidc/client-form.hbs b/ui/app/templates/components/oidc/client-form.hbs new file mode 100644 index 000000000..893ff199a --- /dev/null +++ b/ui/app/templates/components/oidc/client-form.hbs @@ -0,0 +1,99 @@ + + + + + +

+ {{if @model.isNew "Create" "Edit"}} + application +

+
+
+
+
+ + {{#each @model.formFields as |attr|}} + + {{/each}} + + {{! MORE OPTIONS TOGGLE }} + +
+ {{! RADIO CARD + SEARCH SELECT }} +
+

Assign access

+
+ + +
+ {{#if (eq this.radioCardGroupValue "limited")}} + + {{/if}} +
+
+
+ + +
+ {{#if this.invalidFormAlert}} +
+ +
+ {{/if}} +
+
\ No newline at end of file diff --git a/ui/app/templates/components/oidc/client-list.hbs b/ui/app/templates/components/oidc/client-list.hbs new file mode 100644 index 000000000..c60f07514 --- /dev/null +++ b/ui/app/templates/components/oidc/client-list.hbs @@ -0,0 +1,52 @@ +{{#each @model as |client|}} + +
+
+
+ + + {{client.name}} + +
+ Client ID: + {{client.clientId}} +
+
+
+
+
+ + + +
+
+
+
+{{/each}} \ No newline at end of file diff --git a/ui/app/templates/components/oidc/key-form.hbs b/ui/app/templates/components/oidc/key-form.hbs new file mode 100644 index 000000000..2df87e951 --- /dev/null +++ b/ui/app/templates/components/oidc/key-form.hbs @@ -0,0 +1,100 @@ + + + + + +

+ {{if @model.isNew "Create" "Edit"}} + key +

+
+
+ +
+
+ + {{#each @model.formFields as |attr|}} + + {{/each}} +
+ {{! RADIO CARD + SEARCH SELECT }} +
+

Allowed applications

+
+ + +
+ {{#if (eq this.radioCardGroupValue "limited")}} + + {{/if}} +
+
+
+ + +
+ {{#if this.invalidFormAlert}} +
+ +
+ {{/if}} +
+
\ No newline at end of file diff --git a/ui/app/templates/components/oidc/provider-form.hbs b/ui/app/templates/components/oidc/provider-form.hbs new file mode 100644 index 000000000..4a8331dc3 --- /dev/null +++ b/ui/app/templates/components/oidc/provider-form.hbs @@ -0,0 +1,127 @@ + + + + + +

+ {{if @model.isNew "Create" "Edit"}} + provider +

+
+
+ +
+
+ + {{! name field }} + + {{#let (get @model.formFields "1") as |attr|}} + + + {{/let}} + {{! scopesSupported field }} + +
+ {{! RADIO CARD + SEARCH SELECT }} +
+

Allowed applications

+
+ + +
+ {{#if (eq this.radioCardGroupValue "limited")}} + + {{/if}} +
+
+
+ + +
+ {{#if this.invalidFormAlert}} +
+ +
+ {{/if}} +
+
\ No newline at end of file diff --git a/ui/app/templates/components/oidc/provider-list.hbs b/ui/app/templates/components/oidc/provider-list.hbs new file mode 100644 index 000000000..9f20d8972 --- /dev/null +++ b/ui/app/templates/components/oidc/provider-list.hbs @@ -0,0 +1,52 @@ +{{#each @model as |provider|}} + +
+
+
+ + + {{provider.name}} + +
+ Issuer: + {{provider.issuer}} +
+
+
+
+
+ + + +
+
+
+
+{{/each}} \ No newline at end of file diff --git a/ui/app/templates/components/oidc/scope-form.hbs b/ui/app/templates/components/oidc/scope-form.hbs new file mode 100644 index 000000000..b5af5588b --- /dev/null +++ b/ui/app/templates/components/oidc/scope-form.hbs @@ -0,0 +1,113 @@ + + + + + +

+ {{if @model.isNew "Create" "Edit"}} + scope +

+
+
+ +
+
+

+ Providers may reference a set of scopes to make specific identity information available as claims +

+ + {{#each @model.formFields as |field|}} + + {{/each}} +

+ You can use Alt+Tab (Option+Tab on MacOS) in the code editor to skip to the next field. See + . +

+
+
+ + +
+ {{#if this.invalidFormAlert}} +
+ +
+ {{/if}} +
+ + + + + \ No newline at end of file diff --git a/ui/app/templates/components/radio-card.hbs b/ui/app/templates/components/radio-card.hbs index 2a4669686..df29246c3 100644 --- a/ui/app/templates/components/radio-card.hbs +++ b/ui/app/templates/components/radio-card.hbs @@ -3,33 +3,44 @@ class="radio-card {{if (eq @value @groupValue) 'is-selected'}} {{if @disabled 'is-disabled'}}" ...attributes > - {{#if (has-block)}} - {{yield}} - {{else}} -
-
- + + + {{#if (has-block)}} + {{yield}} + {{else}} +
+
+ +
+
+
+ {{@title}} +
+

+ {{@description}} +

+
+
+ {{/if}} +
+ +
-
-
- {{@title}} -
-

- {{@description}} -

-
-
- {{/if}} -
- - -
+ + {{#if (and @disabled @disabledTooltipMessage)}} + +
+ {{@disabledTooltipMessage}} +
+
+ {{/if}} + \ No newline at end of file diff --git a/ui/app/templates/components/transform-role-edit.hbs b/ui/app/templates/components/transform-role-edit.hbs index 7d46520c0..b6ae0bd67 100644 --- a/ui/app/templates/components/transform-role-edit.hbs +++ b/ui/app/templates/components/transform-role-edit.hbs @@ -99,7 +99,6 @@ @value={{get this.model attr.name}} @type={{attr.type}} @isLink={{eq attr.name "transformations"}} - @viewAll="transformations" /> {{else}} {{else}} diff --git a/ui/app/templates/vault/cluster/access.hbs b/ui/app/templates/vault/cluster/access.hbs index 9cd363e32..4f9a8b4f6 100644 --- a/ui/app/templates/vault/cluster/access.hbs +++ b/ui/app/templates/vault/cluster/access.hbs @@ -61,6 +61,13 @@ {{/if}} + {{#if (has-permission "access" routeParams="oidc")}} +
  • + + OIDC Provider + +
  • + {{/if}}
    {{outlet}} diff --git a/ui/app/templates/vault/cluster/access/oidc.hbs b/ui/app/templates/vault/cluster/access/oidc.hbs new file mode 100644 index 000000000..ff76bc722 --- /dev/null +++ b/ui/app/templates/vault/cluster/access/oidc.hbs @@ -0,0 +1,59 @@ +{{#if this.header}} + + +

    + OIDC Provider +

    +
    +
    +
    +

    + Configure Vault to act as an OIDC identity provider, and offer + {{"Vault’s"}} + various authentication + {{#if this.isCta}} +
    + {{/if}} + methods and source of identity to any client applications. + + Learn more + +

    + {{#if this.isCta}} + + {{/if}} +
    + {{#unless this.isCta}} + {{! show tab links in list routes }} +
    + +
    + {{/unless}} +{{/if}} + +{{outlet}} \ No newline at end of file diff --git a/ui/app/templates/vault/cluster/access/oidc/assignments/assignment/details.hbs b/ui/app/templates/vault/cluster/access/oidc/assignments/assignment/details.hbs new file mode 100644 index 000000000..0197809b8 --- /dev/null +++ b/ui/app/templates/vault/cluster/access/oidc/assignments/assignment/details.hbs @@ -0,0 +1,85 @@ + + + + + +

    + {{@model.name}} +

    +
    +
    + +
    + +
    + + + + {{#if @model.canDelete}} + + Delete assignment + +
    + {{/if}} + {{#if @model.canEdit}} + + Edit assignment + + {{/if}} +
    +
    + +
    + + + +
    \ No newline at end of file diff --git a/ui/app/templates/vault/cluster/access/oidc/assignments/assignment/edit.hbs b/ui/app/templates/vault/cluster/access/oidc/assignments/assignment/edit.hbs new file mode 100644 index 000000000..1c302ffa9 --- /dev/null +++ b/ui/app/templates/vault/cluster/access/oidc/assignments/assignment/edit.hbs @@ -0,0 +1,5 @@ + \ No newline at end of file diff --git a/ui/app/templates/vault/cluster/access/oidc/assignments/create.hbs b/ui/app/templates/vault/cluster/access/oidc/assignments/create.hbs new file mode 100644 index 000000000..221e6d790 --- /dev/null +++ b/ui/app/templates/vault/cluster/access/oidc/assignments/create.hbs @@ -0,0 +1,5 @@ + \ No newline at end of file diff --git a/ui/app/templates/vault/cluster/access/oidc/assignments/index.hbs b/ui/app/templates/vault/cluster/access/oidc/assignments/index.hbs new file mode 100644 index 000000000..2eb79dc07 --- /dev/null +++ b/ui/app/templates/vault/cluster/access/oidc/assignments/index.hbs @@ -0,0 +1,71 @@ + + + + Create assignment + + + + +{{#each this.model as |model|}} + +
    +
    +
    + + + {{model.name}} + + {{#if (eq model.name "allow_all")}} +
    + This is a built-in assignment that cannot be modified or deleted. + + Learn more + +
    + {{/if}} +
    +
    + {{#if (not-eq model.name "allow_all")}} +
    +
    + + + +
    +
    + {{/if}} +
    +
    +{{/each}} \ No newline at end of file diff --git a/ui/app/templates/vault/cluster/access/oidc/clients/client.hbs b/ui/app/templates/vault/cluster/access/oidc/clients/client.hbs new file mode 100644 index 000000000..6b7f064e3 --- /dev/null +++ b/ui/app/templates/vault/cluster/access/oidc/clients/client.hbs @@ -0,0 +1,44 @@ +{{#if this.showHeader}} + + + + + +

    + {{this.model.name}} +

    +
    +
    + +
    + +
    +{{/if}} + +{{outlet}} \ No newline at end of file diff --git a/ui/app/templates/vault/cluster/access/oidc/clients/client/details.hbs b/ui/app/templates/vault/cluster/access/oidc/clients/client/details.hbs new file mode 100644 index 000000000..6b4db378f --- /dev/null +++ b/ui/app/templates/vault/cluster/access/oidc/clients/client/details.hbs @@ -0,0 +1,43 @@ + + + {{#if this.model.canDelete}} + + Delete application + +
    + {{/if}} + {{#if this.model.canEdit}} + + Edit application + + {{/if}} +
    +
    + +
    + + + + + + + {{this.model.key}} + + + + + + + + +
    \ No newline at end of file diff --git a/ui/app/templates/vault/cluster/access/oidc/clients/client/edit.hbs b/ui/app/templates/vault/cluster/access/oidc/clients/client/edit.hbs new file mode 100644 index 000000000..657ace87b --- /dev/null +++ b/ui/app/templates/vault/cluster/access/oidc/clients/client/edit.hbs @@ -0,0 +1,5 @@ + \ No newline at end of file diff --git a/ui/app/templates/vault/cluster/access/oidc/clients/client/providers.hbs b/ui/app/templates/vault/cluster/access/oidc/clients/client/providers.hbs new file mode 100644 index 000000000..58fbff753 --- /dev/null +++ b/ui/app/templates/vault/cluster/access/oidc/clients/client/providers.hbs @@ -0,0 +1,13 @@ + +{{#if (gt this.model.length 0)}} + +{{else}} + + + View providers + + +{{/if}} \ No newline at end of file diff --git a/ui/app/templates/vault/cluster/access/oidc/clients/create.hbs b/ui/app/templates/vault/cluster/access/oidc/clients/create.hbs new file mode 100644 index 000000000..b5c962272 --- /dev/null +++ b/ui/app/templates/vault/cluster/access/oidc/clients/create.hbs @@ -0,0 +1,5 @@ + \ No newline at end of file diff --git a/ui/app/templates/vault/cluster/access/oidc/clients/index.hbs b/ui/app/templates/vault/cluster/access/oidc/clients/index.hbs new file mode 100644 index 000000000..ccca4b996 --- /dev/null +++ b/ui/app/templates/vault/cluster/access/oidc/clients/index.hbs @@ -0,0 +1,9 @@ + + + + Create application + + + + + \ No newline at end of file diff --git a/ui/app/templates/vault/cluster/access/oidc/index.hbs b/ui/app/templates/vault/cluster/access/oidc/index.hbs new file mode 100644 index 000000000..8f6d07d6a --- /dev/null +++ b/ui/app/templates/vault/cluster/access/oidc/index.hbs @@ -0,0 +1,14 @@ +
    +

    + Step 1: + Create an application, and obtain the client ID, client secret and issuer URL. +

    +

    + Step 2: + Set up a new auth method for Vault with the client application. +

    +
    + +
    + OIDC configure diagram +
    \ No newline at end of file diff --git a/ui/app/templates/vault/cluster/access/oidc/keys/create.hbs b/ui/app/templates/vault/cluster/access/oidc/keys/create.hbs new file mode 100644 index 000000000..763fb7c11 --- /dev/null +++ b/ui/app/templates/vault/cluster/access/oidc/keys/create.hbs @@ -0,0 +1,5 @@ + \ 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 new file mode 100644 index 000000000..514667a19 --- /dev/null +++ b/ui/app/templates/vault/cluster/access/oidc/keys/index.hbs @@ -0,0 +1,56 @@ + + + + Create key + + + + +{{#each this.model as |model|}} + +
    +
    +
    + + + {{model.name}} + +
    +
    +
    +
    + + + +
    +
    +
    +
    +{{/each}} \ No newline at end of file diff --git a/ui/app/templates/vault/cluster/access/oidc/keys/key.hbs b/ui/app/templates/vault/cluster/access/oidc/keys/key.hbs new file mode 100644 index 000000000..fb219d6da --- /dev/null +++ b/ui/app/templates/vault/cluster/access/oidc/keys/key.hbs @@ -0,0 +1,36 @@ +{{#if this.showHeader}} + + + + + +

    + {{this.model.name}} +

    +
    +
    + +
    + +
    +{{/if}} + +{{outlet}} \ No newline at end of file diff --git a/ui/app/templates/vault/cluster/access/oidc/keys/key/clients.hbs b/ui/app/templates/vault/cluster/access/oidc/keys/key/clients.hbs new file mode 100644 index 000000000..af6a49fa0 --- /dev/null +++ b/ui/app/templates/vault/cluster/access/oidc/keys/key/clients.hbs @@ -0,0 +1,14 @@ + + +{{#if (gt this.model.length 0)}} + +{{else}} + + + Edit key + + +{{/if}} \ No newline at end of file diff --git a/ui/app/templates/vault/cluster/access/oidc/keys/key/details.hbs b/ui/app/templates/vault/cluster/access/oidc/keys/key/details.hbs new file mode 100644 index 000000000..4d208cf32 --- /dev/null +++ b/ui/app/templates/vault/cluster/access/oidc/keys/key/details.hbs @@ -0,0 +1,54 @@ + + + {{#if this.model.canDelete}} + + + + Delete key + + + {{#if (eq this.model.name "default")}} + +
    + This is a built-in key that cannot be deleted. +
    +
    + {{/if}} +
    +
    + {{/if}} + {{#if this.model.canRotate}} + + Rotate key + + {{/if}} + {{#if this.model.canEdit}} + + Edit key + + {{/if}} +
    +
    + +
    + + + + +
    \ No newline at end of file diff --git a/ui/app/templates/vault/cluster/access/oidc/keys/key/edit.hbs b/ui/app/templates/vault/cluster/access/oidc/keys/key/edit.hbs new file mode 100644 index 000000000..9d2052921 --- /dev/null +++ b/ui/app/templates/vault/cluster/access/oidc/keys/key/edit.hbs @@ -0,0 +1,5 @@ + \ No newline at end of file diff --git a/ui/app/templates/vault/cluster/access/oidc/providers/create.hbs b/ui/app/templates/vault/cluster/access/oidc/providers/create.hbs new file mode 100644 index 000000000..08ed12aed --- /dev/null +++ b/ui/app/templates/vault/cluster/access/oidc/providers/create.hbs @@ -0,0 +1,5 @@ + \ No newline at end of file diff --git a/ui/app/templates/vault/cluster/access/oidc/providers/index.hbs b/ui/app/templates/vault/cluster/access/oidc/providers/index.hbs new file mode 100644 index 000000000..7439e03cc --- /dev/null +++ b/ui/app/templates/vault/cluster/access/oidc/providers/index.hbs @@ -0,0 +1,9 @@ + + + + Create provider + + + + + \ No newline at end of file diff --git a/ui/app/templates/vault/cluster/access/oidc/providers/provider.hbs b/ui/app/templates/vault/cluster/access/oidc/providers/provider.hbs new file mode 100644 index 000000000..85875eb25 --- /dev/null +++ b/ui/app/templates/vault/cluster/access/oidc/providers/provider.hbs @@ -0,0 +1,44 @@ +{{#if this.showHeader}} + + + + + +

    + {{this.model.name}} +

    +
    +
    + +
    + +
    +{{/if}} + +{{outlet}} \ No newline at end of file diff --git a/ui/app/templates/vault/cluster/access/oidc/providers/provider/clients.hbs b/ui/app/templates/vault/cluster/access/oidc/providers/provider/clients.hbs new file mode 100644 index 000000000..9e5c5f2c8 --- /dev/null +++ b/ui/app/templates/vault/cluster/access/oidc/providers/provider/clients.hbs @@ -0,0 +1,14 @@ + + +{{#if (gt this.model.length 0)}} + +{{else}} + + + Edit provider + + +{{/if}} \ No newline at end of file diff --git a/ui/app/templates/vault/cluster/access/oidc/providers/provider/details.hbs b/ui/app/templates/vault/cluster/access/oidc/providers/provider/details.hbs new file mode 100644 index 000000000..7034ab7dc --- /dev/null +++ b/ui/app/templates/vault/cluster/access/oidc/providers/provider/details.hbs @@ -0,0 +1,52 @@ + + + {{#if this.model.canDelete}} + + + + Delete provider + + + {{#if (eq this.model.name "default")}} + +
    + This is a built-in provider that cannot be deleted. +
    +
    + {{/if}} +
    +
    + {{/if}} + {{#if this.model.canEdit}} + + Edit provider + + {{/if}} +
    +
    +
    + + + +
    \ No newline at end of file diff --git a/ui/app/templates/vault/cluster/access/oidc/providers/provider/edit.hbs b/ui/app/templates/vault/cluster/access/oidc/providers/provider/edit.hbs new file mode 100644 index 000000000..a20ade9ac --- /dev/null +++ b/ui/app/templates/vault/cluster/access/oidc/providers/provider/edit.hbs @@ -0,0 +1,5 @@ + \ No newline at end of file diff --git a/ui/app/templates/vault/cluster/access/oidc/scopes/create.hbs b/ui/app/templates/vault/cluster/access/oidc/scopes/create.hbs new file mode 100644 index 000000000..d0adb8c53 --- /dev/null +++ b/ui/app/templates/vault/cluster/access/oidc/scopes/create.hbs @@ -0,0 +1,5 @@ + \ 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 new file mode 100644 index 000000000..b521c0a61 --- /dev/null +++ b/ui/app/templates/vault/cluster/access/oidc/scopes/index.hbs @@ -0,0 +1,73 @@ + + + + Create scope + + + + +{{#if (gt this.model.length 0)}} + {{#each this.model as |model|}} + +
    +
    +
    + + + {{model.name}} + +
    +
    +
    +
    + + + +
    +
    +
    +
    + {{/each}} +{{else}} + +
    +

    Use scope to define identity information about the authenticated user. + + Learn more. + +

    +
    + + Create scope + +
    +
    +
    +{{/if}} \ No newline at end of file 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 new file mode 100644 index 000000000..bd2612051 --- /dev/null +++ b/ui/app/templates/vault/cluster/access/oidc/scopes/scope/details.hbs @@ -0,0 +1,65 @@ + + + + + +

    + {{this.model.name}} +

    +
    +
    + +
    + +
    + + + + {{#if this.model.canDelete}} + + Delete scope + +
    + {{/if}} + {{#if this.model.canEdit}} + + Edit scope + + {{/if}} +
    +
    + +
    + + + +
    \ 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 new file mode 100644 index 000000000..3ba271d32 --- /dev/null +++ b/ui/app/templates/vault/cluster/access/oidc/scopes/scope/edit.hbs @@ -0,0 +1,5 @@ + \ No newline at end of file diff --git a/ui/lib/core/addon/components/alert-inline.js b/ui/lib/core/addon/components/alert-inline.js index ec670d849..8c7482b40 100644 --- a/ui/lib/core/addon/components/alert-inline.js +++ b/ui/lib/core/addon/components/alert-inline.js @@ -13,12 +13,12 @@ import { messageTypes } from 'core/helpers/message-types'; * * ``` * - * @param type=null{String} - The alert type passed to the message-types helper. - * @param [message=null]{String} - The message to display within the alert. - * @param [paddingTop=false]{Boolean} - Whether or not to add padding above component. - * @param [isMarginless=false]{Boolean} - Whether or not to remove margin bottom below component. - * @param [sizeSmall=false]{Boolean} - Whether or not to display a small font with padding below of alert message. - * @param [mimicRefresh=false]{Boolean} - If true will display a loading icon when attributes change (e.g. when a form submits and the alert message changes). + * @param {string} type=null - The alert type passed to the message-types helper. + * @param {string} [message=null] - The message to display within the alert. + * @param {boolean} [paddingTop=false] - Whether or not to add padding above component. + * @param {boolean} [isMarginless=false] - Whether or not to remove margin bottom below component. + * @param {boolean} [sizeSmall=false] - Whether or not to display a small font with padding below of alert message. + * @param {boolean} [mimicRefresh=false] - If true will display a loading icon when attributes change (e.g. when a form submits and the alert message changes). */ export default class AlertInlineComponent extends Component { diff --git a/ui/lib/core/addon/components/form-field-groups.hbs b/ui/lib/core/addon/components/form-field-groups.hbs index c4803be13..3bb2c37ed 100644 --- a/ui/lib/core/addon/components/form-field-groups.hbs +++ b/ui/lib/core/addon/components/form-field-groups.hbs @@ -3,8 +3,8 @@ {{#if (or (not @renderGroup) (and @renderGroup (eq group @renderGroup)))}} {{#if (eq group "default")}} {{#each fields as |attr|}} - {{! template-lint-configure simple-unless "warn" }} - {{#unless (and (not-eq @mode "create") (eq attr.name "name"))}} + {{! SHOW ALL FIELDS IF CREATING A NEW MODEL }} + {{#if (or (eq @mode "create") @model.isNew)}} - {{/unless}} + {{else}} + {{! OTHERWISE WE'RE EDITING }} + {{#if (or (eq attr.name "name") (attr.options.editDisabled))}} + + {{else}} + + {{/if}} + {{/if}} {{/each}} {{else}} {{#each fields as |attr|}} - + {{! SHOW ALL FIELDS IF CREATING A NEW MODEL }} + {{#if (or (eq @mode "create") @model.isNew)}} + + {{else}} + {{! OTHERWISE WE'RE EDITING }} + {{#if (or (eq attr.name "name") (eq attr.options.editDisabled true))}} + + {{else}} + + {{/if}} + {{/if}} {{/each}}
    {{/if}} diff --git a/ui/lib/core/addon/components/form-field-label.hbs b/ui/lib/core/addon/components/form-field-label.hbs index a6779bba6..f6de039c9 100644 --- a/ui/lib/core/addon/components/form-field-label.hbs +++ b/ui/lib/core/addon/components/form-field-label.hbs @@ -12,9 +12,9 @@

    {{@subText}} {{#if @docLink}} - + See our documentation - + for help. {{/if}}

    diff --git a/ui/lib/core/addon/components/form-field.hbs b/ui/lib/core/addon/components/form-field.hbs index a877a6269..2a42b76b1 100644 --- a/ui/lib/core/addon/components/form-field.hbs +++ b/ui/lib/core/addon/components/form-field.hbs @@ -1,5 +1,5 @@ {{! template-lint-configure simple-unless "warn" }} -
    +
    {{#unless (or (eq @attr.type "boolean") @@ -21,9 +21,9 @@ {{/unless}} {{#if @attr.options.possibleValues}} {{#if (eq @attr.options.editType "radio")}} -
    +
    {{#each (path-or-array @attr.options.possibleValues @model) as |val|}} -
    +
    + {{#if this.validationError}} + + {{/if}} {{else if (eq @attr.options.editType "mountAccessor")}} {{@attr.options.subText}} {{#if @attr.options.docLink}} - + See our documentation - + for help. {{/if}} @@ -173,9 +179,9 @@ {{or @attr.options.defaultSubText "Vault will use the engine default."}} {{#if @attr.options.docLink}} - + See our documentation - + for help. {{/if}} @@ -240,38 +246,41 @@ /> {{else if (eq @attr.options.editType "json")}} {{! JSON Editor }} - - {{#if @attr.options.allowReset}} - - {{/if}} - + {{#let (get @model this.valuePath) as |value|}} + + {{#if @attr.options.allowReset}} + + {{/if}} + + {{/let}} {{#if @attr.options.subText}}

    {{@attr.options.subText}} {{#if @attr.options.docLink}} - + See our documentation - + for help. {{/if}}

    @@ -282,6 +291,7 @@ data-test-input={{@attr.name}} id={{@attr.name}} readonly={{this.isReadOnly}} + disabled={{and @attr.options.editDisabled (not @model.isNew)}} autocomplete="off" spellcheck="false" value={{or (get @model this.valuePath) @attr.options.defaultValue}} diff --git a/ui/lib/core/addon/components/info-table-item-array.hbs b/ui/lib/core/addon/components/info-table-item-array.hbs index d1db9818c..d4bd43604 100644 --- a/ui/lib/core/addon/components/info-table-item-array.hbs +++ b/ui/lib/core/addon/components/info-table-item-array.hbs @@ -1,58 +1,79 @@ -
    +{{! the class linkable-item is needed for the read-more component }} +
    {{#if @isLink}}
    - {{#each this.displayArrayAmended as |name|}} - {{#if (is-wildcard-string name)}} - {{#let (filter-wildcard name this.allOptions) as |wildcardCount|}} - {{name}} - - includes - {{if wildcardCount wildcardCount 0}} - {{if (eq wildcardCount 1) @wildcardLabel (pluralize @wildcardLabel)}} - - {{#if (eq this.displayArrayAmended.lastObject name)}} - - View all {{@viewAll}} + + {{#each this.displayArrayTruncated as |name|}} + {{#if (is-wildcard-string name)}} + {{#let (filter-wildcard name this.allOptions) as |wildcardCount|}} + {{name}} + + includes + {{if wildcardCount wildcardCount 0}} + {{if (eq wildcardCount 1) @wildcardLabel (pluralize @wildcardLabel)}} + + {{#if (eq this.displayArrayTruncated.lastObject name)}} + + View all {{lowercase @label}}. + + {{/if}} + {{/let}} + {{else}} + {{#if (is-array this.itemRoute)}} + + {{name}} + + {{else}} + + {{name}} {{/if}} - {{/let}} - {{else}} - - {{name}} - - {{/if}} - {{#if - (or - (and (not-eq name this.displayArrayAmended.lastObject) this.wildcardInDisplayArray) - (not-eq name this.displayArrayAmended.lastObject) - ) - }} - ,  - {{/if}} - {{#if (and (eq name this.displayArrayAmended.lastObject) (gte this.displayArray.length 10))}} - -  and - {{dec 5 this.displayArray.length}} - others.  - - {{/if}} - {{#if (and (eq name this.displayArrayAmended.lastObject) (gte this.displayArray.length 10))}} - - View all {{this.viewAll}} - - {{/if}} - {{/each}} + {{/if}} + {{#if + (or + (and (not-eq name this.displayArrayTruncated.lastObject) this.wildcardInDisplayArray) + (not-eq name this.displayArrayTruncated.lastObject) + ) + }} + ,  + {{/if}} + {{#unless this.doNotTruncate}} + {{#if (and (eq name this.displayArrayTruncated.lastObject) (gte @displayArray.length 10))}} + {{! dec is a math helper that decrements by 5 the length of the array ex: 11-5 = "and 6 others."}} + +  and + {{dec 5 @displayArray.length}} + others.  + + {{/if}} + {{#if (and (eq name this.displayArrayTruncated.lastObject) (gte @displayArray.length 10))}} + {{#if (is-array @rootRoute)}} + + View all {{lowercase @label}}. + + {{else}} + + View all {{lowercase @label}}. + + {{/if}} + {{/if}} + {{/unless}} + {{/each}} +
    {{else}} {{if - (gte this.displayArray.length 10) - (concat this.displayArray ", and " (dec 5 this.displayArray.length) " more.") - this.displayArray + (gte @displayArray.length 10) + (concat @displayArray ", and " (dec 5 @displayArray.length) " more.") + @displayArray }} {{/if}} diff --git a/ui/lib/core/addon/components/info-table-item-array.js b/ui/lib/core/addon/components/info-table-item-array.js index 7d3dcad27..2c316de6e 100644 --- a/ui/lib/core/addon/components/info-table-item-array.js +++ b/ui/lib/core/addon/components/info-table-item-array.js @@ -15,25 +15,25 @@ import { isWildcardString } from 'vault/helpers/is-wildcard-string'; * @example * ```js * * ``` * - * @param displayArray=null {array} - This array of data to be displayed. If there are more than 10 items in the array only five will show and a count of the other number in the array will show. - * @param [isLink] {Boolean} - Indicates if the item should contain a link-to component. Only setup for arrays, but this could be changed if needed. - * @param [rootRoute="vault.cluster.secrets.backend.list-root"] - {string} - Tells what route the link should go to when selecting "view all". - * @param [itemRoute=vault.cluster.secrets.backend.show] - {string} - Tells what route the link should go to when selecting the individual item. - * @param [modelType] {string} - Tells which model you want data for the allOptions to be returned from. Used in conjunction with the the isLink. - * @param [wildcardLabel] {String} - when you want the component to return a count on the model for options returned when using a wildcard you must provide a label of the count e.g. role. Should be singular. - * @param [queryParam] {String} - If you want to specific a tab for the View All XX to display to. Ex: role - * @param [backend] {String} - To specify which backend to point the link to. - * @param [viewAll] {String} - Specify the word at the end of the link View all xx. + * @param {string} label - used to render lowercased display text for "View all
    -
    +
    + {{#if @addCopyButton}} +
    + + + +
    + {{/if}} {{#if (has-block)}} {{yield}} {{else if this.valueIsBoolean}} @@ -40,7 +52,7 @@ No {{/if}} - {{! alwaysRender is still true }} + {{! @alwaysRender (this.isVisible) is still true }} {{else if this.valueIsEmpty}} {{#if @defaultShown}} {{@defaultShown}} @@ -52,14 +64,16 @@ {{else}} {{#if (eq @type "array")}} {{else}} {{#if @tooltipText}} diff --git a/ui/lib/core/addon/components/radio-button.hbs b/ui/lib/core/addon/components/radio-button.hbs index 901962a23..0917c85a9 100644 --- a/ui/lib/core/addon/components/radio-button.hbs +++ b/ui/lib/core/addon/components/radio-button.hbs @@ -1 +1,8 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/ui/lib/core/addon/components/read-more.js b/ui/lib/core/addon/components/read-more.js index a8a2b3488..7b53df2d6 100644 --- a/ui/lib/core/addon/components/read-more.js +++ b/ui/lib/core/addon/components/read-more.js @@ -6,7 +6,8 @@ import { tracked } from '@glimmer/tracking'; /** * @module ReadMore - * ReadMore components are used to wrap long text that we'd like to show as one line initially with the option to expand and read. Text which is shorter than the surrounding div will not truncate or show the See More button. + * ReadMore components are used to wrap long text that we'd like to show as one line initially with the option to expand and read. + * Text which is shorter than the surrounding div will not truncate or show the See More button. * * @example * ```js diff --git a/ui/lib/core/addon/components/search-select-with-modal.hbs b/ui/lib/core/addon/components/search-select-with-modal.hbs index e441647f5..600ba8106 100644 --- a/ui/lib/core/addon/components/search-select-with-modal.hbs +++ b/ui/lib/core/addon/components/search-select-with-modal.hbs @@ -75,7 +75,7 @@ {{/if}} {{#if this.newModelRecord}} {}, inputValue: computed(function () { return []; @@ -56,7 +57,11 @@ export default Component.extend({ shouldRenderName: false, disallowNewItems: false, passObject: false, - + objectKeys: null, + idKey: computed('objectKeys', function () { + // if objectKeys exists, then use the first element of the array as the identifier + return this.objectKeys ? this.objectKeys[0] : 'id'; + }), init() { this._super(...arguments); this.set('selectedOptions', this.inputValue || []); @@ -73,7 +78,7 @@ export default Component.extend({ }, formatOptions: function (options) { options = options.toArray().map((option) => { - option.searchText = `${option.name} ${option.id}`; + option.searchText = `${option.name} ${option[this.idKey]}`; return option; }); let allOptions = options.toArray().map((option) => { @@ -81,15 +86,19 @@ export default Component.extend({ }); this.set('allOptions', allOptions); // used by filter-wildcard helper let formattedOptions = this.selectedOptions.map((option) => { - let matchingOption = options.findBy('id', option); + let matchingOption = options.findBy(this.idKey, option); + // an undefined matchingOption means a selectedOption, on edit, didn't match a model returned from the query and therefore doesn't exist + let addTooltip = matchingOption ? false : true; // add tooltip to let user know the selection can be discarded options.removeObject(matchingOption); return { id: option, name: matchingOption ? matchingOption.name : option, searchText: matchingOption ? matchingOption.searchText : option, + addTooltip, + // conditionally spread configured object if we're using the dynamic idKey + ...(this.idKey !== 'id' && this.customizeObject(matchingOption)), }; }); - this.set('selectedOptions', formattedOptions); if (this.options) { options = this.options.concat(options).uniq(); @@ -103,6 +112,10 @@ export default Component.extend({ } return; } + if (this.idKey !== 'id') { + // if passing a dynamic idKey, then display it in the dropdown beside the name + this.set('shouldRenderName', true); + } for (let modelType of this.models) { if (modelType.includes('identity')) { this.set('shouldRenderName', true); @@ -112,6 +125,9 @@ export default Component.extend({ if (this.backend) { queryOptions = { backend: this.backend }; } + if (this.queryObject) { + queryOptions = this.queryObject; + } let options = yield this.store.query(modelType, queryOptions); this.formatOptions(options); } catch (err) { @@ -135,11 +151,7 @@ export default Component.extend({ }).on('didInsertElement'), handleChange() { if (this.selectedOptions.length && typeof this.selectedOptions.firstObject === 'object') { - if (this.passObject) { - this.onChange(Array.from(this.selectedOptions, (option) => ({ id: option.id, isNew: !!option.new }))); - } else { - this.onChange(Array.from(this.selectedOptions, (option) => option.id)); - } + this.onChange(Array.from(this.selectedOptions, (option) => this.customizeObject(option))); } else { this.onChange(this.selectedOptions); } @@ -157,7 +169,7 @@ export default Component.extend({ //----- adapted from ember-power-select-with-create addCreateOption(term, results) { if (this.shouldShowCreate(term, results)) { - const name = `Add new ${singularize(this.label)}: ${term}`; + const name = `Add new ${singularize(this.label || 'item')}: ${term}`; const suggestion = { __isSuggestion__: true, __value__: term, @@ -172,7 +184,30 @@ export default Component.extend({ return filterOptions(options || [], searchText, matcher); }, // ----- - + customizeObject(option) { + if (!option) return; + // if passObject=true return object, otherwise return string of option id + if (this.passObject) { + let additionalKeys; + if (this.objectKeys) { + // pull attrs corresponding to objectKeys from model record, add to the selected option (object) and send to the parent + additionalKeys = Object.fromEntries(this.objectKeys.map((key) => [key, option[key]])); + // filter any undefined attrs, which means the model did not have a value for that attr + // no value could mean the model was not hydrated, the record is new or the model doesn't have that attribute + Object.keys(additionalKeys).forEach((key) => { + if (additionalKeys[key] === undefined) { + delete additionalKeys[key]; + } + }); + } + return { + id: option.id, + isNew: !!option.new, + ...additionalKeys, + }; + } + return option.id; + }, actions: { onChange(val) { this.onChange(val); diff --git a/ui/lib/core/addon/components/tool-tip.js b/ui/lib/core/addon/components/tool-tip.js index 7f8f0f705..88c893f48 100644 --- a/ui/lib/core/addon/components/tool-tip.js +++ b/ui/lib/core/addon/components/tool-tip.js @@ -7,7 +7,7 @@ export default class ToolTipComponent extends Component { return this.args.delay || 200; } get horizontalPosition() { - return this.args.delay || 'auto-right'; + return this.args.horizontalPosition || 'auto-right'; } toggleState({ dropdown, action }) { diff --git a/ui/lib/core/addon/templates/components/box-radio.hbs b/ui/lib/core/addon/templates/components/box-radio.hbs index edfa73c46..8f65b4df4 100644 --- a/ui/lib/core/addon/templates/components/box-radio.hbs +++ b/ui/lib/core/addon/templates/components/box-radio.hbs @@ -14,7 +14,7 @@ id={{@type}} name={{@groupName}} class="radio" - disabled={{@disabled}} + @disabled={{@disabled}} @value={{@type}} @groupValue={{@groupValue}} @onChange={{@onRadioChange}} @@ -42,7 +42,7 @@ id={{@type}} name={{@groupName}} class="radio" - disabled={{@disabled}} + @disabled={{@disabled}} @value={{@type}} @groupValue={{@mountType}} @onChange={{@onRadioChange}} diff --git a/ui/lib/core/addon/templates/components/readonly-form-field.hbs b/ui/lib/core/addon/templates/components/readonly-form-field.hbs index fac4da9dd..3e7552e35 100644 --- a/ui/lib/core/addon/templates/components/readonly-form-field.hbs +++ b/ui/lib/core/addon/templates/components/readonly-form-field.hbs @@ -29,6 +29,7 @@ autocomplete="off" spellcheck="false" value={{@value}} + disabled={{true}} readonly class="field input is-readOnly" type={{@attr.type}} diff --git a/ui/lib/core/addon/templates/components/search-select.hbs b/ui/lib/core/addon/templates/components/search-select.hbs index 1f75e4ca0..00cab345e 100644 --- a/ui/lib/core/addon/templates/components/search-select.hbs +++ b/ui/lib/core/addon/templates/components/search-select.hbs @@ -11,7 +11,7 @@ }} {{else}} {{#if this.label}} -