From 1779d0b264e6d76f78ea8cf86dbb90daef4cc6ed Mon Sep 17 00:00:00 2001 From: Vault Automation Date: Fri, 5 Jun 2026 10:58:33 -0600 Subject: [PATCH] Backport [UI] Ember Data Migration - Transform Role and Transformation views | VAULT-45708 | VAULT-45709 into ce/main (#15234) * no-op commit * migrates transform role and transformation views * Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> * fixed failing tests and updated query selectors --------- Co-authored-by: Mohit Ojha Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> --- ui/app/components/transform-role-edit.hbs | 105 ++--- ui/app/components/transform-role-edit.js | 334 ++++++++------ ui/app/components/transformation-edit.hbs | 195 ++++++-- ui/app/components/transformation-edit.js | 415 ++++++++++++------ ui/app/forms/transform/role.ts | 35 ++ ui/app/forms/transform/transformation.ts | 108 +++++ .../cluster/secrets/backend/create-root.js | 15 +- .../cluster/secrets/backend/secret-edit.js | 72 ++- .../acceptance/enterprise-transform-test.js | 38 +- .../components/transform-role-edit-test.js | 22 - .../components/transform-role-edit-test.ts | 101 +++++ 11 files changed, 1033 insertions(+), 407 deletions(-) create mode 100644 ui/app/forms/transform/role.ts create mode 100644 ui/app/forms/transform/transformation.ts delete mode 100644 ui/tests/integration/components/transform-role-edit-test.js create mode 100644 ui/tests/integration/components/transform-role-edit-test.ts diff --git a/ui/app/components/transform-role-edit.hbs b/ui/app/components/transform-role-edit.hbs index 053d0247f6..cf3df81963 100644 --- a/ui/app/components/transform-role-edit.hbs +++ b/ui/app/components/transform-role-edit.hbs @@ -5,36 +5,30 @@ <:breadcrumbs> - + -{{#if (eq this.mode "show")}} +{{#if (eq @mode "show")}} - {{#if this.model.updatePath.canDelete}} -
{{/if}} - {{#if this.model.updatePath.canUpdate}} + {{#if @capabilities.canUpdate}} Edit role @@ -43,28 +37,52 @@
{{/if}} -{{#if (or (eq this.mode "edit") (eq this.mode "create"))}} -
+{{#if (or (eq @mode "edit") (eq @mode "create"))}} +
- - - {{#each this.model.attrs as |attr|}} - {{#if (and (eq this.mode "edit") attr.options.readOnly)}} - + + + {{#each @form.formFields as |field|}} + {{#if (eq field.name "transformations")}} +
+ + {{#if (and this.modelValidations.transformations (not this.modelValidations.transformations.isValid))}} + + {{this.modelValidations.transformations.errors}} + + {{/if}} +
{{else}} - + {{/if}} {{/each}}
- - {{#if (eq this.mode "create")}} + + {{#if (eq @mode "create")}} {{else}} @@ -72,8 +90,7 @@ @text="Cancel" @color="secondary" @route="vault.cluster.secrets.backend.show" - @models={{array this.model.backend (concat "role/" this.model.id)}} - @query={{hash tab="role"}} + @models={{array @form.data.backend (concat "role/" @form.data.name)}} /> {{/if}} @@ -81,25 +98,13 @@ {{else}}
- {{#each this.model.attrs as |attr|}} - {{#if (eq attr.type "object")}} - - {{else if (eq attr.type "array")}} - - {{else}} - - {{/if}} + {{#each @form.formFields as |field|}} + {{/each}}
{{/if}} \ No newline at end of file diff --git a/ui/app/components/transform-role-edit.js b/ui/app/components/transform-role-edit.js index a7c8672544..ae71c1b5c8 100644 --- a/ui/app/components/transform-role-edit.js +++ b/ui/app/components/transform-role-edit.js @@ -3,152 +3,224 @@ * SPDX-License-Identifier: BUSL-1.1 */ -import TransformBase, { addToList, removeFromList } from './transform-edit-base'; +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { action } from '@ember/object'; import { service } from '@ember/service'; -import { computed } from '@ember/object'; +import { SecretsApiTransformListTransformationsListEnum } from '@hashicorp/vault-client-typescript'; -export default TransformBase.extend({ - flashMessages: service(), - store: service(), - initialTransformations: null, +/** + * @module TransformRoleEdit + * `TransformRoleEdit` is a component that allows you to create/edit or view a transform role. + * + * @example + * ```js + * + * ``` + * @param {object} form - RoleForm instance with data and formFields. + * @param {object} capabilities - Object with canDelete, canUpdate, canRead capabilities. + * @param {string} mode - Is either show, create or edit. + */ - init() { - this._super(...arguments); - this.set('initialTransformations', this.model.transformations); - }, +export default class TransformRoleEditComponent extends Component { + @service flashMessages; + @service router; + @service api; - breadcrumbs: computed('root', 'title', function () { + @tracked errorMessage = ''; + @tracked modelValidations; + @tracked transformations = []; + + // Non-tracked: used only to diff added/removed transformations after save + initialTransformations = []; + + constructor() { + super(...arguments); + this.initialTransformations = [...(this.args.form.data.transformations ?? [])]; + this.fetchTransformations(); + } + + async fetchTransformations() { + try { + const resp = await this.api.secrets.transformListTransformations( + this.args.form.data.backend, + SecretsApiTransformListTransformationsListEnum.TRUE + ); + this.transformations = (resp.keys ?? []).map((key) => ({ id: key })); + } catch { + // swallow errors, SearchSelect will fall back to string-list + } + } + + get breadcrumbs() { + const backend = this.args.form?.data?.backend; + const name = this.args.form?.data?.name; return [ - { label: 'Vault', text: 'Vault', icon: 'vault', path: 'vault.cluster.dashboard' }, - { text: 'Secrets engines', path: 'vault.cluster.secrets.backends' }, - this.root, - { label: this.title, text: this.title }, + { label: 'Vault', route: 'vault.cluster.dashboard', icon: 'vault' }, + { label: 'Secrets engines', route: 'vault.cluster.secrets.backends' }, + { + label: backend, + route: 'vault.cluster.secrets.backend.list-root', + model: backend, + query: { tab: 'role' }, + }, + { label: this.title }, + { label: this.args.mode === 'create' ? 'role' : name }, ]; - }), + } - title: computed('mode', function () { - if (this.mode === 'create') { + get title() { + if (this.args.mode === 'create') { return 'Create role'; - } else if (this.mode === 'edit') { + } else if (this.args.mode === 'edit') { return 'Edit role'; } else { return 'Role'; } - }), + } - subtitle: computed('mode', 'model.id', function () { - if (this.mode === 'create' || this.mode === 'edit') return; + get subtitle() { + if (this.args.mode === 'show') { + return this.args.form?.data?.name; + } + return ''; + } - return this.model.id; - }), + // Reads a transformation, updates its allowed_roles (add/remove this role), then saves. + async syncTransformationForRole(transformationName, roleName, backend, syncAction) { + let currentAllowedRoles; + try { + const resp = await this.api.secrets.transformReadTransformation(transformationName, backend); + const data = resp?.data || resp || {}; + currentAllowedRoles = data.allowed_roles || []; + } catch { + // If the transformation can't be read, skip it + return { transformationName, syncAction, errorStatus: null, skipped: true }; + } - handleUpdateTransformations(updateTransformations, roleId, type = 'update') { - if (!updateTransformations) return; - const backend = this.model.backend; - const promises = updateTransformations.map((transform) => { - return this.store - .queryRecord('transform', { - backend, - id: transform.id, - }) - .then(function (transformation) { - let roles = transformation.allowed_roles; - if (transform.action === 'ADD') { - roles = addToList(roles, roleId); - } else if (transform.action === 'REMOVE') { - roles = removeFromList(roles, roleId); - } + let updatedRoles; + if (syncAction === 'ADD') { + updatedRoles = currentAllowedRoles.includes(roleName) + ? currentAllowedRoles + : [...currentAllowedRoles, roleName]; + } else { + updatedRoles = currentAllowedRoles.filter((r) => r !== roleName); + } - transformation.setProperties({ - backend, - allowed_roles: roles, - }); - - return transformation.save().catch((e) => { - return { errorStatus: e.httpStatus, ...transform }; - }); - }); - }); - - Promise.all(promises).then((res) => { - const hasError = res.find((r) => !!r.errorStatus); - if (hasError) { - const errorAdding = res.find((r) => r.errorStatus === 403 && r.action === 'ADD'); - const errorRemoving = res.find((r) => r.errorStatus === 403 && r.action === 'REMOVE'); - - let message = - 'The edits to this role were successful, but allowed_roles for its transformations was not edited due to a lack of permissions.'; - if (type === 'create') { - message = - 'Transformations have been attached to this role, but the role was not added to those transformations’ allowed_roles due to a lack of permissions.'; - } else if (errorAdding && errorRemoving) { - message = - 'This role was edited to both add and remove transformations; however, this role was not added or removed from those transformations’ allowed_roles due to a lack of permissions.'; - } else if (errorAdding) { - message = - 'This role was edited to include new transformations, but this role was not added to those transformations’ allowed_roles due to a lack of permissions.'; - } else if (errorRemoving) { - message = - 'This role was edited to remove transformations, but this role was not removed from those transformations’ allowed_roles due to a lack of permissions.'; - } - this.flashMessages.info(message, { - sticky: true, - priority: 300, - }); - } - }); - }, - - actions: { - createOrUpdate(type, event) { - event.preventDefault(); - - this.applyChanges('save', () => { - const roleId = this.model.id; - const newModelTransformations = this.model.transformations; - - if (!this.initialTransformations) { - this.handleUpdateTransformations( - newModelTransformations.map((t) => ({ - id: t, - action: 'ADD', - })), - roleId, - type - ); - return; - } - - const updateTransformations = [...newModelTransformations, ...this.initialTransformations] - .map((t) => { - if (this.initialTransformations.indexOf(t) < 0) { - return { - id: t, - action: 'ADD', - }; - } - if (newModelTransformations.indexOf(t) < 0) { - return { - id: t, - action: 'REMOVE', - }; - } - return null; - }) - .filter((t) => !!t); - this.handleUpdateTransformations(updateTransformations, roleId); + try { + await this.api.secrets.transformWriteTransformation(transformationName, backend, { + allowed_roles: updatedRoles, }); - }, + return { transformationName, syncAction, errorStatus: null }; + } catch (writeErr) { + const { status } = await this.api.parseError(writeErr); + return { transformationName, syncAction, errorStatus: status }; + } + } - delete() { - const roleId = this.model?.id; - const roleTransformations = this.model?.transformations || []; - const updateTransformations = roleTransformations.map((t) => ({ - id: t, - action: 'REMOVE', - })); - this.handleUpdateTransformations(updateTransformations, roleId); - this.applyDelete(); - }, - }, -}); + // Diffs current vs initial transformations, syncs allowed_roles on each + // affected transformation, then shows a single contextual flash if any failed. + async handleTransformationSync(roleName, backend, type = 'update') { + const currentTransformations = this.args.form.data.transformations ?? []; + const initialTransformations = this.initialTransformations; + + let syncOps; + if (type === 'create') { + syncOps = currentTransformations.map((t) => ({ id: t, syncAction: 'ADD' })); + } else { + const added = currentTransformations.filter((t) => !initialTransformations.includes(t)); + const removed = initialTransformations.filter((t) => !currentTransformations.includes(t)); + syncOps = [ + ...added.map((t) => ({ id: t, syncAction: 'ADD' })), + ...removed.map((t) => ({ id: t, syncAction: 'REMOVE' })), + ]; + } + + if (syncOps.length === 0) return; + + const results = await Promise.all( + syncOps.map(({ id, syncAction }) => this.syncTransformationForRole(id, roleName, backend, syncAction)) + ); + + const errors = results.filter((r) => r.errorStatus === 403); + if (errors.length === 0) return; + + const errorAdding = errors.some((r) => r.syncAction === 'ADD'); + const errorRemoving = errors.some((r) => r.syncAction === 'REMOVE'); + + let message; + if (type === 'create') { + message = + 'Transformations have been attached to this role, but the role was not added to those transformations\u2019 allowed_roles due to a lack of permissions.'; + } else if (errorAdding && errorRemoving) { + message = + 'This role was edited to both add and remove transformations; however, this role was not added or removed from those transformations\u2019 allowed_roles due to a lack of permissions.'; + } else if (errorAdding) { + message = + 'This role was edited to include new transformations, but this role was not added to those transformations\u2019 allowed_roles due to a lack of permissions.'; + } else { + message = + 'This role was edited to remove transformations, but this role was not removed from those transformations\u2019 allowed_roles due to a lack of permissions.'; + } + + this.flashMessages.info(message, { sticky: true, priority: 300 }); + } + + // Removes this role from all of its transformations' allowed_roles on delete. + async cleanupTransformationsOnDelete(roleName, backend) { + const transformations = this.args.form.data.transformations ?? []; + if (transformations.length === 0) return; + + await Promise.all( + transformations.map((t) => this.syncTransformationForRole(t, roleName, backend, 'REMOVE')) + ); + } + + transition(route = 'show') { + this.errorMessage = ''; + this.modelValidations = null; + const { backend, name } = this.args.form.data; + if (route === 'list') { + this.router.transitionTo('vault.cluster.secrets.backend.list-root', backend, { + queryParams: { tab: 'role' }, + }); + return; + } + this.router.transitionTo('vault.cluster.secrets.backend.show', `role/${name}`); + } + + @action async createOrUpdate(event) { + event.preventDefault(); + + const { isValid, state, invalidFormMessage, data } = this.args.form.toJSON(); + this.modelValidations = isValid ? null : state; + this.errorMessage = invalidFormMessage; + if (!isValid) return; + + const { name, transformations, backend } = data; + + const isCreate = this.args.mode === 'create'; + try { + await this.api.secrets.transformWriteRole(name, backend, { transformations }); + this.flashMessages.success('Role saved.'); + await this.handleTransformationSync(name, backend, isCreate ? 'create' : 'update'); + this.transition(); + } catch (e) { + const { message } = await this.api.parseError(e); + this.errorMessage = message; + } + } + + @action async onDelete() { + const { name, backend } = this.args.form.data; + try { + await this.api.secrets.transformDeleteRole(name, backend); + this.flashMessages.success('Role deleted.'); + await this.cleanupTransformationsOnDelete(name, backend); + this.transition('list'); + } catch (e) { + const { message } = await this.api.parseError(e); + this.flashMessages.danger(message); + } + } +} diff --git a/ui/app/components/transformation-edit.hbs b/ui/app/components/transformation-edit.hbs index f1d1833d46..0c34b03853 100644 --- a/ui/app/components/transformation-edit.hbs +++ b/ui/app/components/transformation-edit.hbs @@ -1,25 +1,19 @@ {{! -Copyright IBM Corp. 2016, 2025 -SPDX-License-Identifier: BUSL-1.1 + Copyright IBM Corp. 2016, 2026 + SPDX-License-Identifier: BUSL-1.1 }} <:breadcrumbs> - + -{{#if (eq this.mode "show")}} +{{#if (eq @mode "show")}} - {{#if this.model.updatePath.canDelete}} - {{#if (gt this.model.allowed_roles.length 0)}} + {{#if @capabilities.canDelete}} + {{#if (gt @form.data.allowed_roles.length 0)}} {{/if}}
{{/if}} - {{#if this.model.updatePath.canUpdate}} - {{#if (gt this.model.allowed_roles.length 0)}} + {{#if @capabilities.canUpdate}} + {{#if (gt @form.data.allowed_roles.length 0)}} {{else}} - + Edit transformation {{/if}} @@ -58,29 +59,162 @@ SPDX-License-Identifier: BUSL-1.1
{{/if}} -{{#if (eq this.mode "edit")}} - -{{else if (eq this.mode "create")}} - +{{#if (or (eq @mode "edit") (eq @mode "create"))}} +
+
+ + + {{#each this.visibleFormFields as |field|}} + {{#if (eq field.name "template")}} +
+ + {{#if (and this.modelValidations.template (not this.modelValidations.template.isValid))}} + + {{this.modelValidations.template.errors}} + + {{/if}} +
+ {{else if (eq field.name "allowed_roles")}} +
+ +
+ {{else}} + + {{/if}} + {{/each}} +
+
+ + + {{#if (eq @mode "create")}} + + {{else}} + + {{/if}} + +
+
{{else}} - +
+ {{#each this.visibleFormFields as |field|}} + {{#if (eq field.name "allowed_roles")}} + + {{else}} + + {{/if}} + {{/each}} +
+ +
+ +
+

Encode

+
+ + To test the encoding capability of your transformation, use the following command. It will output an encoded_value. + +
+
+ {{#let (concat "vault write " @form.data.backend "/encode/" this.cliCommand) as |copyEncodeCommand|}} + + {{/let}} +
+
+
+

Decode

+
+ + To test decoding capability of your transformation, use the encoded_value in the following command. It should + return your original input. + +
+
+ {{#let (concat "vault write " @form.data.backend "/decode/" this.cliCommand) as |copyDecodeCommand|}} + + {{/let}} +
+
+
{{/if}}

Deleting the - {{this.model.name}} + {{@form.data.name}} transformation means that the underlying keys are lost and the data encoded by the transformation are unrecoverable and cannot be decoded.

- +
{{#if this.isEditModalActive}} @@ -96,12 +230,7 @@ SPDX-License-Identifier: BUSL-1.1 - + diff --git a/ui/app/components/transformation-edit.js b/ui/app/components/transformation-edit.js index 435baa715d..44fd2da670 100644 --- a/ui/app/components/transformation-edit.js +++ b/ui/app/components/transformation-edit.js @@ -3,156 +3,301 @@ * SPDX-License-Identifier: BUSL-1.1 */ -import TransformBase, { addToList, removeFromList } from './transform-edit-base'; +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { action } from '@ember/object'; import { service } from '@ember/service'; -import { computed } from '@ember/object'; +import { + SecretsApiTransformListRolesListEnum, + SecretsApiTransformListTemplatesListEnum, +} from '@hashicorp/vault-client-typescript'; -export default TransformBase.extend({ - flashMessages: service(), - store: service(), - initialRoles: null, +/** + * @module TransformationEdit + * `TransformationEdit` is a component that allows you to create/edit or view a transformation. + * + * @example + * ```js + * + * ``` + * @param {object} form - TransformationForm instance with data and formFields. + * @param {object} capabilities - Object with canDelete, canUpdate, canRead capabilities. + * @param {string} mode - Is either show, create or edit. + */ - init() { - this._super(...arguments); - if (!this.model) return; - this.set('initialRoles', this.model.allowed_roles); - }, +export default class TransformationEditComponent extends Component { + @service flashMessages; + @service router; + @service api; - breadcrumbs: computed('root', 'title', function () { + @tracked errorMessage = ''; + @tracked modelValidations; + @tracked roles = []; + @tracked templates = []; + @tracked isDeleteModalActive = false; + @tracked isEditModalActive = false; + + // Non-tracked: used only to diff added/removed roles after save + initialAllowedRoles = []; + + constructor() { + super(...arguments); + this.initialAllowedRoles = [...(this.args.form.data.allowed_roles ?? [])]; + this.fetchRoles(); + this.fetchTemplates(); + } + + get visibleFormFields() { + const type = this.args.form.data.type; + return this.args.form.formFields.filter((field) => { + switch (field.name) { + case 'tweak_source': + return type === 'fpe'; + case 'masking_character': + return type === 'masking'; + case 'template': + return type !== 'tokenization'; + case 'mapping_mode': + case 'convergent': + case 'max_ttl': + case 'stores': + return type === 'tokenization'; + default: + return true; + } + }); + } + + async fetchRoles() { + try { + const resp = await this.api.secrets.transformListRoles( + this.args.form.data.backend, + SecretsApiTransformListRolesListEnum.TRUE + ); + this.roles = (resp.keys ?? []).map((key) => ({ id: key })); + } catch { + // swallow errors, SearchSelect will fall back to string-list + } + } + + async fetchTemplates() { + try { + const resp = await this.api.secrets.transformListTemplates( + this.args.form.data.backend, + SecretsApiTransformListTemplatesListEnum.TRUE + ); + this.templates = (resp.keys ?? []).map((key) => ({ id: key })); + } catch { + // swallow errors, SearchSelect will fall back to string-list + } + } + + get breadcrumbs() { + const backend = this.args.form?.data?.backend; + const name = this.args.form?.data?.name; return [ - { label: 'Vault', text: 'Vault', icon: 'vault', path: 'vault.cluster.dashboard' }, - { text: 'Secrets engines', path: 'vault.cluster.secrets.backends' }, - this.root, - { label: this.title, text: this.title }, + { label: 'Vault', route: 'vault.cluster.dashboard', icon: 'vault' }, + { label: 'Secrets engines', route: 'vault.cluster.secrets.backends' }, + { + label: backend, + route: 'vault.cluster.secrets.backend.list-root', + model: backend, + }, + { label: this.title }, + { label: this.args.mode === 'create' ? 'transformation' : name }, ]; - }), + } - title: computed('mode', function () { - if (this.mode === 'create') { - return 'Create Transformation'; - } else if (this.mode === 'edit') { - return 'Edit Transformation'; + get title() { + if (this.args.mode === 'create') { + return 'Create transformation'; + } else if (this.args.mode === 'edit') { + return 'Edit transformation'; } else { return 'Transformation'; } - }), + } - subtitle: computed('mode', 'model.id', function () { - if (this.mode === 'create' || this.mode === 'edit') return; - - return this.model.id; - }), - - async updateOrCreateRole(role, transformationId, backend) { - const roleRecord = await this.store - .queryRecord('transform/role', { - backend, - id: role.id, - }) - .catch((e) => { - if (e.httpStatus !== 403 && role.action === 'ADD') { - // If role doesn't yet exist, create it with this transformation attached - var newRole = this.store.createRecord('transform/role', { - id: role.id, - name: role.id, - transformations: [transformationId], - backend, - }); - return newRole.save().catch((e) => { - return { - errorStatus: e.httpStatus, - ...role, - action: 'CREATE', - }; - }); - } - - return { - ...role, - errorStatus: e.httpStatus, - }; - }); - // if an error occurs while querying the role, exit function and return the error - if (roleRecord.errorStatus) return roleRecord; - // otherwise update the role with the transformation and save - let transformations = roleRecord.transformations; - if (role.action === 'ADD') { - transformations = addToList(transformations, transformationId); - } else if (role.action === 'REMOVE') { - transformations = removeFromList(transformations, transformationId); + get subtitle() { + if (this.args.mode === 'show') { + return this.args.form?.data?.name; } - roleRecord.setProperties({ - backend, - transformations, - }); - return roleRecord.save().catch((e) => { - return { - errorStatus: e.httpStatus, - ...role, - }; - }); - }, + return ''; + } - handleUpdateRoles(updateRoles, transformationId) { - if (!updateRoles) return; - const { backend } = this.model; - updateRoles.forEach(async (record) => { - // For each role that needs to be updated, update the role with the transformation. - const updateOrCreateResponse = await this.updateOrCreateRole(record, transformationId, backend); - // If an error was returned, check error type and show a message. - const errorStatus = updateOrCreateResponse?.errorStatus; - let message; - if (errorStatus == 403) { - message = `The edits to this transformation were successful, but transformations for the role ${record.id} were not edited due to a lack of permissions.`; - } else if (errorStatus) { - message = `You've edited the allowed_roles for this transformation. However, there was a problem updating the role: ${record.id}.`; + get cliCommand() { + const { type, allowed_roles, tweak_source, name } = this.args.form?.data ?? {}; + if (!name) return ''; + + const rolesArr = allowed_roles ?? []; + const wildCardRole = rolesArr.find((role) => role.includes('*')); + let role = ''; + if (rolesArr.length === 1 && !wildCardRole) { + role = rolesArr[0]; + } + + let tweak = ''; + if (type === 'fpe' && tweak_source === 'supplied') { + tweak = 'tweak='; + } + + return `${role} value= ${tweak} transformation=${name}`; + } + + isWildcard(roleName) { + return typeof roleName === 'string' && roleName.includes('*'); + } + + async syncRoleForTransformation(roleName, transformationName, backend, syncAction) { + if (this.isWildcard(roleName)) return; + + let currentTransformations; + try { + const resp = await this.api.secrets.transformReadRole(roleName, backend); + const data = resp?.data || resp || {}; + currentTransformations = data.transformations || []; + } catch (readErr) { + const { status } = await this.api.parseError(readErr); + if (status === 403) { + this.flashMessages.info( + `The transformation was saved, but the role "${roleName}" could not be updated due to a lack of permissions.`, + { sticky: true, priority: 300 } + ); + return; } - this.flashMessages.info(message, { - sticky: true, - priority: 300, - }); - }); - }, - - isWildcard(role) { - if (typeof role === 'string') { - return role.indexOf('*') >= 0; + // Role not found (404) or other non-403 error + if (syncAction === 'ADD') { + // Auto-create the role with this transformation + try { + await this.api.secrets.transformWriteRole(roleName, backend, { + transformations: [transformationName], + }); + } catch (createErr) { + const { message } = await this.api.parseError(createErr); + this.flashMessages.info( + `The transformation was saved, but the role "${roleName}" could not be created: ${message}`, + { sticky: true, priority: 300 } + ); + } + } + // For REMOVE: role doesn't exist, nothing to do + return; } - if (role && role.id) { - return role.id.indexOf('*') >= 0; + + let updatedTransformations; + if (syncAction === 'ADD') { + updatedTransformations = currentTransformations.includes(transformationName) + ? currentTransformations + : [...currentTransformations, transformationName]; + } else { + updatedTransformations = currentTransformations.filter((t) => t !== transformationName); } - return false; - }, - actions: { - createOrUpdate(type, event) { - event.preventDefault(); - - this.applyChanges('save', () => { - const transformationId = this.model.id || this.model.name; - const newModelRoles = this.model.allowed_roles || []; - const initialRoles = this.initialRoles || []; - - const updateRoles = [...newModelRoles, ...initialRoles] - .filter((r) => !this.isWildcard(r)) // CBS TODO: expand wildcards into included roles instead - .map((role) => { - if (initialRoles.indexOf(role) < 0) { - return { - id: role, - action: 'ADD', - }; - } - if (newModelRoles.indexOf(role) < 0) { - return { - id: role, - action: 'REMOVE', - }; - } - return null; - }) - .filter((r) => !!r); - this.handleUpdateRoles(updateRoles, transformationId); + try { + await this.api.secrets.transformWriteRole(roleName, backend, { + transformations: updatedTransformations, }); - }, - }, -}); + } catch (writeErr) { + const { status, message } = await this.api.parseError(writeErr); + const detail = status === 403 ? `due to a lack of permissions` : message; + this.flashMessages.info( + `The transformation was saved, but the role "${roleName}" could not be updated: ${detail}`, + { sticky: true, priority: 300 } + ); + } + } + + // Diffs current vs initial allowed_roles and syncs each changed role in parallel. + async handleRoleSync(transformationName, backend) { + const currentRoles = this.args.form.data.allowed_roles ?? []; + const initialRoles = this.initialAllowedRoles; + + const addedRoles = currentRoles.filter((r) => !this.isWildcard(r) && !initialRoles.includes(r)); + const removedRoles = initialRoles.filter((r) => !this.isWildcard(r) && !currentRoles.includes(r)); + + await Promise.all([ + ...addedRoles.map((r) => this.syncRoleForTransformation(r, transformationName, backend, 'ADD')), + ...removedRoles.map((r) => this.syncRoleForTransformation(r, transformationName, backend, 'REMOVE')), + ]); + } + + transition(route = 'show') { + this.errorMessage = ''; + this.modelValidations = null; + const { name } = this.args.form.data; + if (route === 'list') { + const { backend } = this.args.form.data; + this.router.transitionTo('vault.cluster.secrets.backend.list-root', backend, { + queryParams: { tab: 'transformations' }, + }); + } else if (route === 'edit') { + this.router.transitionTo('vault.cluster.secrets.backend.edit', name); + } else { + this.router.transitionTo('vault.cluster.secrets.backend.show', name); + } + } + + @action async createOrUpdate(event) { + event.preventDefault(); + + const { isValid, state, invalidFormMessage, data } = this.args.form.toJSON(); + this.modelValidations = isValid ? null : state; + this.errorMessage = invalidFormMessage; + if (!isValid) return; + + const { + name, + backend, + type, + tweak_source, + masking_character, + template, + allowed_roles, + deletion_allowed, + mapping_mode, + convergent, + max_ttl, + stores, + } = data; + + const templateValue = Array.isArray(template) ? template[0] : template; + + try { + await this.api.secrets.transformWriteTransformation(name, backend, { + type, + tweak_source, + masking_character, + template: templateValue, + allowed_roles, + deletion_allowed, + mapping_mode, + convergent, + max_ttl, + stores, + }); + this.flashMessages.success('Transformation saved.'); + await this.handleRoleSync(name, backend); + this.transition(); + } catch (e) { + const { message } = await this.api.parseError(e); + this.errorMessage = message; + } + } + + @action async onDelete() { + const { name, backend } = this.args.form.data; + try { + await this.api.secrets.transformDeleteTransformation(name, backend); + this.flashMessages.success('Transformation deleted.'); + this.transition('list'); + } catch (e) { + const { message } = await this.api.parseError(e); + this.flashMessages.danger(message); + } + } + + @action confirmEdit() { + this.isEditModalActive = false; + this.transition('edit'); + } +} diff --git a/ui/app/forms/transform/role.ts b/ui/app/forms/transform/role.ts new file mode 100644 index 0000000000..9fd690142a --- /dev/null +++ b/ui/app/forms/transform/role.ts @@ -0,0 +1,35 @@ +/** + * Copyright IBM Corp. 2016, 2026 + * SPDX-License-Identifier: BUSL-1.1 + */ + +import Form from 'vault/forms/form'; +import FormField from 'vault/utils/forms/field'; +import type { Validations } from 'vault/app-types'; + +type RoleData = { + name?: string; + transformations?: string[]; + backend?: string; +}; + +export default class RoleForm extends Form { + idPrefix = 'role/'; + + formFields = [ + new FormField('name', 'string', { + editDisabled: true, + subText: 'The name for your role. This cannot be edited later.', + }), + new FormField('transformations', 'array', { + isSectionHeader: true, + label: 'Transformations', + subText: 'Select which transformations this role will have access to. It must already exist.', + }), + ]; + + validations: Validations = { + name: [{ type: 'presence', message: 'Name is required.' }], + transformations: [{ type: 'presence', message: 'At least one transformation is required.' }], + }; +} diff --git a/ui/app/forms/transform/transformation.ts b/ui/app/forms/transform/transformation.ts new file mode 100644 index 0000000000..5053b98a0f --- /dev/null +++ b/ui/app/forms/transform/transformation.ts @@ -0,0 +1,108 @@ +/** + * Copyright IBM Corp. 2016, 2026 + * SPDX-License-Identifier: BUSL-1.1 + */ + +import Form from 'vault/forms/form'; +import FormField from 'vault/utils/forms/field'; +import type { Validations } from 'vault/app-types'; + +const TYPES = [ + { value: 'fpe', displayName: 'Format Preserving Encryption (FPE)' }, + { value: 'masking', displayName: 'Masking' }, + { value: 'tokenization', displayName: 'Tokenization' }, +]; + +const TWEAK_SOURCE = [ + { value: 'supplied', displayName: 'supplied' }, + { value: 'generated', displayName: 'generated' }, + { value: 'internal', displayName: 'internal' }, +]; + +type TransformationData = { + name?: string; + type?: string; + tweak_source?: string; + masking_character?: string; + template?: string[]; + allowed_roles?: string[]; + deletion_allowed?: boolean; + convergent?: boolean; + stores?: string[]; + mapping_mode?: string; + max_ttl?: string; + backend?: string; +}; + +export default class TransformationForm extends Form { + formFields = [ + new FormField('name', 'string', { + editDisabled: true, + subText: 'The name for your transformation. This cannot be edited later.', + }), + new FormField('type', 'string', { + editDisabled: true, + possibleValues: TYPES, + defaultValue: 'fpe', + label: 'Type', + subText: + 'Vault provides two types of transformations: Format Preserving Encryption (FPE) is reversible, while Masking is not. This cannot be edited later.', + }), + new FormField('deletion_allowed', 'boolean', { + label: 'Allow deletion', + subText: + 'If checked, this transform can be deleted otherwise deletion is blocked. Note that deleting the transform deletes the underlying key which makes decoding of tokenized values impossible without restoring from a backup.', + }), + new FormField('tweak_source', 'string', { + possibleValues: TWEAK_SOURCE, + defaultValue: 'supplied', + label: 'Tweak source', + subText: + 'A tweak value is used when performing FPE transformations. This can be supplied, generated, or internal.', + }), + new FormField('masking_character', 'string', { + defaultValue: '*', + label: 'Masking character', + subText: 'Specify which character you\u2019d like to mask your data.', + }), + new FormField('template', 'array', { + isSectionHeader: true, + label: 'Template', + subText: + 'Templates allow Vault to determine what and how to capture the value to be transformed. Type to use an existing template or create a new one.', + }), + new FormField('allowed_roles', 'array', { + isSectionHeader: true, + label: 'Allowed roles', + subText: 'Search for an existing role, type a new role to create it, or use a wildcard (*).', + }), + new FormField('mapping_mode', 'string', { + defaultValue: 'default', + label: 'Mapping mode', + subText: + 'Specifies the mapping mode for stored tokenization values. "default" is strongly recommended for highest security, "exportable" allows for all plaintexts to be decoded via the export-decoded endpoint in an emergency.', + }), + new FormField('convergent', 'boolean', { + label: 'Use convergent tokenization', + subText: + 'If checked, tokenization of the same plaintext more than once results in the same token. Defaults to false as unique tokens are more desirable from a security standpoint.', + }), + new FormField('max_ttl', 'string', { + editType: 'ttl', + defaultValue: '0', + label: 'Maximum TTL (time-to-live) of a token', + subText: 'If \u201c0\u201d or unspecified, tokens may have no expiration.', + }), + new FormField('stores', 'array', { + editType: 'stringArray', + label: 'Stores', + subText: + 'The list of tokenization stores to use for tokenization state. Vault\u2019s internal storage is used by default.', + }), + ]; + + validations: Validations = { + name: [{ type: 'presence', message: 'Name is required.' }], + template: [{ type: 'presence', message: 'Template is required.' }], + }; +} diff --git a/ui/app/routes/vault/cluster/secrets/backend/create-root.js b/ui/app/routes/vault/cluster/secrets/backend/create-root.js index f7cfacad68..e94b41fd11 100644 --- a/ui/app/routes/vault/cluster/secrets/backend/create-root.js +++ b/ui/app/routes/vault/cluster/secrets/backend/create-root.js @@ -12,6 +12,8 @@ import TotpKeyForm from 'vault/forms/totp/key'; import SshRoleForm from 'vault/forms/ssh/role'; import AlphabetForm from 'vault/forms/transform/alphabet'; import TemplateForm from 'vault/forms/transform/template'; +import RoleForm from 'vault/forms/transform/role'; +import TransformationForm from 'vault/forms/transform/transformation'; import { KeyManagementUpdateKeyRequestTypeEnum } from '@hashicorp/vault-client-typescript'; const secretModel = (store, backend, key) => { @@ -21,13 +23,6 @@ const secretModel = (store, backend, key) => { return model; }; -const transformModel = (queryParams) => { - const modelType = 'transform'; - if (!queryParams || !queryParams.itemType) return modelType; - - return `${modelType}/${queryParams.itemType}`; -}; - export default EditBase.extend({ store: service(), @@ -82,9 +77,11 @@ export default EditBase.extend({ if (modelType === 'transform/template') { return new TemplateForm({ backend }, { isNew: true }); } - // TODO: Remove once all transform sub-types (template, role, transformation) are migrated to Form classes. + if (modelType === 'transform/role') { + return new RoleForm({ backend }, { isNew: true }); + } if (modelType === 'transform') { - modelType = transformModel(transition.to.queryParams); + return new TransformationForm({ backend, type: 'fpe' }, { isNew: true }); } if (modelType === 'database/connection' && transition.to?.queryParams?.itemType === 'role') { modelType = 'database/role'; diff --git a/ui/app/routes/vault/cluster/secrets/backend/secret-edit.js b/ui/app/routes/vault/cluster/secrets/backend/secret-edit.js index 405cf65f81..3f1cfe8707 100644 --- a/ui/app/routes/vault/cluster/secrets/backend/secret-edit.js +++ b/ui/app/routes/vault/cluster/secrets/backend/secret-edit.js @@ -20,6 +20,8 @@ import TotpKeyForm from 'vault/forms/totp/key'; import SshRoleForm from 'vault/forms/ssh/role'; import AlphabetForm from 'vault/forms/transform/alphabet'; import TemplateForm from 'vault/forms/transform/template'; +import RoleForm from 'vault/forms/transform/role'; +import TransformationForm from 'vault/forms/transform/transformation'; import Form from 'vault/forms/form'; import { SecretsApiKeyManagementListKmsProvidersForKeyListEnum, @@ -426,6 +428,62 @@ export default Route.extend({ }; }, + async fetchTransformRole(backend, name) { + const resp = await this.api.secrets.transformReadRole(name, backend); + const data = resp.data || {}; + return new RoleForm({ name, backend, transformations: data.transformations || [] }, { isNew: false }); + }, + + async fetchTransformRoleCapabilities(backend, name) { + const rolePath = this.capabilitiesService.pathFor('transformRole', { backend, name }); + const rolesPath = this.capabilitiesService.pathFor('transformRoles', { backend }); + + const capabilities = await this.capabilitiesService.fetch([rolePath, rolesPath]); + + return { + canDelete: capabilities[rolePath]?.canDelete, + canUpdate: capabilities[rolePath]?.canUpdate, + canRead: capabilities[rolePath]?.canRead, + canList: capabilities[rolesPath]?.canList, + }; + }, + + async fetchTransformTransformation(backend, name) { + const resp = await this.api.secrets.transformReadTransformation(name, backend); + const data = resp.data || resp || {}; + return new TransformationForm( + { + name, + backend, + type: data.type || 'fpe', + tweak_source: data.tweak_source, + masking_character: data.masking_character, + template: data.template ? [data.template] : [], + allowed_roles: data.allowed_roles || [], + deletion_allowed: data.deletion_allowed, + mapping_mode: data.mapping_mode, + convergent: data.convergent, + max_ttl: data.max_ttl, + stores: data.stores || [], + }, + { isNew: false } + ); + }, + + async fetchTransformTransformationCapabilities(backend, name) { + const transformationPath = this.capabilitiesService.pathFor('transformTransformation', { backend, name }); + const transformationsPath = this.capabilitiesService.pathFor('transformTransformations', { backend }); + + const capabilities = await this.capabilitiesService.fetch([transformationPath, transformationsPath]); + + return { + canDelete: capabilities[transformationPath]?.canDelete, + canUpdate: capabilities[transformationPath]?.canUpdate, + canRead: capabilities[transformationPath]?.canRead, + canList: capabilities[transformationsPath]?.canList, + }; + }, + async handleSecretModelError(capabilitiesPromise, secretId, modelType, error) { // capabilities is a promise proxy, not a real object // to work around this we explicitly assign it to a const and await it @@ -462,7 +520,6 @@ export default Route.extend({ secret = secret.replace('role/', ''); } let secretModel; - let transformRoles; let capabilities; if (modelType === 'totp-key') { @@ -483,6 +540,12 @@ export default Route.extend({ } else if (modelType === 'transform/template') { secretModel = await this.fetchTransformTemplate(backend, secret); capabilities = await this.fetchTransformTemplateCapabilities(backend, secret); + } else if (modelType === 'transform/role') { + secretModel = await this.fetchTransformRole(backend, secret); + capabilities = await this.fetchTransformRoleCapabilities(backend, secret); + } else if (modelType === 'transform') { + secretModel = await this.fetchTransformTransformation(backend, secret); + capabilities = await this.fetchTransformTransformationCapabilities(backend, secret); } else { capabilities = await this.capabilities(secret, modelType); try { @@ -498,15 +561,9 @@ export default Route.extend({ } } - // fetch roles for transform type to display in detail view - if (modelType === 'transform') { - transformRoles = await this.fetchTransformRoles(backend); - } - return { secret: secretModel, capabilities, - transformRoles, }; }, @@ -537,7 +594,6 @@ export default Route.extend({ backend, preferAdvancedEdit, backendType, - transformRoles: model.transformRoles, }); }, diff --git a/ui/tests/acceptance/enterprise-transform-test.js b/ui/tests/acceptance/enterprise-transform-test.js index 24e8354d0f..a0b6ce24c3 100644 --- a/ui/tests/acceptance/enterprise-transform-test.js +++ b/ui/tests/acceptance/enterprise-transform-test.js @@ -92,11 +92,11 @@ module('Acceptance | Enterprise | Transform secrets', function (hooks) { ); assert.ok(GENERAL.emptyStateTitle, 'renders empty state'); assert - .dom('.active[data-test-secret-list-tab="Transformations"]') + .dom(`.active${GENERAL.secretTab('Transformations')}`) .exists('Has Transformations tab which is active'); - assert.dom('[data-test-secret-list-tab="Roles"]').exists('Has Roles tab'); - assert.dom('[data-test-secret-list-tab="Templates"]').exists('Has Templates tab'); - assert.dom('[data-test-secret-list-tab="Alphabets"]').exists('Has Alphabets tab'); + assert.dom(GENERAL.secretTab('Roles')).exists('Has Roles tab'); + assert.dom(GENERAL.secretTab('Templates')).exists('Has Templates tab'); + assert.dom(GENERAL.secretTab('Alphabets')).exists('Has Alphabets tab'); }); test('it can create a transformation and add itself to the role attached', async function (assert) { @@ -142,7 +142,7 @@ module('Acceptance | Enterprise | Transform secrets', function (hooks) { `/vault/secrets-engines/${backend}/show/${transformationName}`, 'redirects to show transformation page after submit' ); - await click(`[data-test-secret-breadcrumb="${backend}"] a`); + await click(GENERAL.breadcrumbLink(backend)); assert.strictEqual( currentURL(), `/vault/secrets-engines/${backend}/list`, @@ -157,13 +157,13 @@ module('Acceptance | Enterprise | Transform secrets', function (hooks) { await mountBackend('transform', backend); // create transformation without role await newTransformation(backend, 'a-transformation', true); - await click(`[data-test-secret-breadcrumb="${backend}"] a`); + await click(GENERAL.breadcrumbLink(backend)); assert.strictEqual( currentURL(), `/vault/secrets-engines/${backend}/list`, 'Links back to list view from breadcrumb' ); - await click('[data-test-secret-list-tab="Roles"]'); + await click(GENERAL.secretTab('Roles')); assert.strictEqual( currentURL(), `/vault/secrets-engines/${backend}/list?tab=role`, @@ -186,7 +186,7 @@ module('Acceptance | Enterprise | Transform secrets', function (hooks) { `/vault/secrets-engines/${backend}/show/role/${roleName}`, 'redirects to show role page after submit' ); - await click(`[data-test-secret-breadcrumb="${backend}"] a`); + await click(GENERAL.breadcrumbLink(backend)); assert.strictEqual( currentURL(), `/vault/secrets-engines/${backend}/list?tab=role`, @@ -203,7 +203,7 @@ module('Acceptance | Enterprise | Transform secrets', function (hooks) { await newRole(backend, roleName); await transformationsPage.visitShow({ backend, id: transformation }); await settled(); - assert.dom('[data-test-row-value="Allowed roles"]').hasText(roleName); + assert.dom(GENERAL.infoRowValue('Allowed roles')).hasText(roleName); }); test('it shows a message if an update fails after save', async function (assert) { @@ -217,7 +217,7 @@ module('Acceptance | Enterprise | Transform secrets', function (hooks) { await newRole(backend, roleName); await settled(); await transformationsPage.visitShow({ backend, id: transformation }); - assert.dom('[data-test-row-value="Allowed roles"]').hasText(roleName); + assert.dom(GENERAL.infoRowValue('Allowed roles')).hasText(roleName); // Edit transformation await click('[data-test-edit-link]'); assert.dom('#transformation-edit-modal').exists('Confirmation modal appears'); @@ -230,17 +230,17 @@ module('Acceptance | Enterprise | Transform secrets', function (hooks) { ); // remove role await settled(); - await click('#allowed_roles [data-test-selected-list-button="delete"]'); + await click(`#allowed_roles ${GENERAL.searchSelect.removeSelected}`); await click(GENERAL.submitButton); - assert.dom('.flash-message.is-info').exists('Shows info message since role could not be updated'); + assert.dom(GENERAL.flashMessage).exists('Shows info message since role could not be updated'); assert.strictEqual( currentURL(), `/vault/secrets-engines/${backend}/show/${transformation}`, 'Correctly links to show page for secret' ); assert - .dom('[data-test-row-value="Allowed roles"]') + .dom(GENERAL.infoRowValue('Allowed roles')) .doesNotExist('Allowed roles are no longer on the transformation'); }); @@ -249,7 +249,7 @@ module('Acceptance | Enterprise | Transform secrets', function (hooks) { await visit('/vault/secrets-engines/enable'); const backend = `transform-${uuidv4()}`; await mountBackend('transform', backend); - await click('[data-test-secret-list-tab="Templates"]'); + await click(GENERAL.secretTab('Templates')); assert.strictEqual( currentURL(), @@ -283,7 +283,7 @@ module('Acceptance | Enterprise | Transform secrets', function (hooks) { 'Links to template edit page' ); await settled(); - assert.dom('[data-test-input="name"]').hasAttribute('readonly'); + assert.dom(GENERAL.inputByAttr('name')).hasAttribute('readonly'); }); test('it allows creation and edit of an alphabet', async function (assert) { @@ -291,7 +291,7 @@ module('Acceptance | Enterprise | Transform secrets', function (hooks) { await visit('/vault/secrets-engines/enable'); const backend = `transform-${uuidv4()}`; await mountBackend('transform', backend); - await click('[data-test-secret-list-tab="Alphabets"]'); + await click(GENERAL.secretTab('Alphabets')); assert.strictEqual( currentURL(), @@ -312,8 +312,8 @@ module('Acceptance | Enterprise | Transform secrets', function (hooks) { `/vault/secrets-engines/${backend}/show/alphabet/${alphabetName}`, 'redirects to show alphabet page after submit' ); - assert.dom('[data-test-row-value="Name"]').hasText(alphabetName); - assert.dom('[data-test-row-value="Alphabet"]').hasText('aeiou'); + assert.dom(GENERAL.infoRowValue('Name')).hasText(alphabetName); + assert.dom(GENERAL.infoRowValue('Alphabet')).hasText('aeiou'); await alphabetsPage.editLink(); await settled(); assert.strictEqual( @@ -321,6 +321,6 @@ module('Acceptance | Enterprise | Transform secrets', function (hooks) { `/vault/secrets-engines/${backend}/edit/alphabet/${alphabetName}`, 'Links to alphabet edit page' ); - assert.dom('[data-test-input="name"]').hasAttribute('readonly'); + assert.dom(GENERAL.inputByAttr('name')).hasAttribute('readonly'); }); }); diff --git a/ui/tests/integration/components/transform-role-edit-test.js b/ui/tests/integration/components/transform-role-edit-test.js deleted file mode 100644 index b9e85e99ce..0000000000 --- a/ui/tests/integration/components/transform-role-edit-test.js +++ /dev/null @@ -1,22 +0,0 @@ -/** - * Copyright IBM Corp. 2016, 2025 - * SPDX-License-Identifier: BUSL-1.1 - */ - -import { module, skip } from 'qunit'; -import { setupRenderingTest } from 'vault/tests/helpers'; -import { render } from '@ember/test-helpers'; -import hbs from 'htmlbars-inline-precompile'; - -module('Integration | Component | transform-role-edit', function (hooks) { - setupRenderingTest(hooks); - - skip('it renders', async function (assert) { - // TODO: Fill out these tests, merging without to unblock other work - await render(hbs` - - `); - - assert.dom(this.element).hasText('template block text'); - }); -}); diff --git a/ui/tests/integration/components/transform-role-edit-test.ts b/ui/tests/integration/components/transform-role-edit-test.ts new file mode 100644 index 0000000000..2ed724569c --- /dev/null +++ b/ui/tests/integration/components/transform-role-edit-test.ts @@ -0,0 +1,101 @@ +/** + * Copyright IBM Corp. 2016, 2026 + * SPDX-License-Identifier: BUSL-1.1 + */ + +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'vault/tests/helpers'; +import { render, click } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; +import RoleForm from 'vault/forms/transform/role'; +import sinon from 'sinon'; +import type ApiService from 'vault/services/api'; + +module('Integration | Component | transform-role-edit', function (hooks) { + setupRenderingTest(hooks); + + hooks.beforeEach(function () { + const router = this.owner.lookup('service:router') as unknown as Record; + router['transitionTo'] = sinon.stub(); + + this.set('capabilities', { + canDelete: true, + canUpdate: true, + canRead: true, + }); + }); + + test('it renders in show mode', async function (assert) { + this.set( + 'form', + new RoleForm( + { + name: 'my-role', + transformations: ['my-transformation'], + backend: 'transform', + }, + { isNew: false } + ) + ); + this.set('mode', 'show'); + + await render( + hbs`` + ); + + assert.dom('[data-test-edit-link]').exists('renders toolbar edit link'); + assert.dom('[data-test-field]').doesNotExist('does not render form fields in show mode'); + }); + + test('it renders in create mode', async function (assert) { + this.set('form', new RoleForm({ backend: 'transform' }, { isNew: true })); + this.set('mode', 'create'); + + await render( + hbs`` + ); + + assert.dom('[data-test-submit]').exists('renders submit button'); + assert.dom('[data-test-submit]').hasText('Create role'); + }); + + test('it renders in edit mode', async function (assert) { + this.set( + 'form', + new RoleForm( + { + name: 'my-role', + transformations: ['my-transformation'], + backend: 'transform', + }, + { isNew: false } + ) + ); + this.set('mode', 'edit'); + + await render( + hbs`` + ); + + assert.dom('[data-test-submit]').exists('renders submit button'); + assert.dom('[data-test-submit]').hasText('Save'); + assert.dom('[data-test-input="name"]').hasAttribute('readonly', '', 'name is readonly in edit mode'); + }); + + test('it calls onDelete and transitions to list', async function (assert) { + const api = this.owner.lookup('service:api') as unknown as ApiService; + const deleteStub = sinon.stub(api.secrets, 'transformDeleteRole').resolves(); + + this.set('form', new RoleForm({ name: 'my-role', backend: 'transform' }, { isNew: false })); + this.set('mode', 'show'); + + await render( + hbs`` + ); + + await click('[data-test-delete]'); + + assert.ok(deleteStub.calledWith('my-role', 'transform'), 'calls transformDeleteRole with correct args'); + deleteStub.restore(); + }); +});