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 <mohit.ojha@hashicorp.com>
Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
This commit is contained in:
Vault Automation 2026-06-05 10:58:33 -06:00 committed by GitHub
parent c0d95b0acf
commit 1779d0b264
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 1033 additions and 407 deletions

View file

@ -5,36 +5,30 @@
<Page::Header @title={{this.title}} @subtitle={{this.subtitle}}>
<:breadcrumbs>
<KeyValueHeader
@baseKey={{hash display=this.model.id id=this.model.idForNav}}
@path="vault.cluster.secrets.backend.list"
@mode={{this.mode}}
@root={{this.breadcrumbs}}
@showCurrent={{true}}
/>
<Page::Breadcrumbs @breadcrumbs={{this.breadcrumbs}} />
</:breadcrumbs>
</Page::Header>
{{#if (eq this.mode "show")}}
{{#if (eq @mode "show")}}
<Toolbar>
<ToolbarActions>
{{#if this.model.updatePath.canDelete}}
<ConfirmAction
@buttonText="Delete role"
{{#if @capabilities.canDelete}}
<Hds::Button
@text="Delete role"
@color="secondary"
class="toolbar-button"
@buttonColor="secondary"
@onConfirmAction={{action "delete"}}
@confirmMessage="Deleting this role means that youll need to recreate it and reassign any existing transformations to use it again."
data-test-transformation-role-delete
data-test-delete
{{on "click" this.onDelete}}
/>
<div class="toolbar-separator"></div>
{{/if}}
{{#if this.model.updatePath.canUpdate}}
{{#if @capabilities.canUpdate}}
<ToolbarSecretLink
@secret={{concat this.model.idPrefix this.model.id}}
@secret={{concat "role/" @form.data.name}}
@backend={{@form.data.backend}}
@mode="edit"
data-test-edit-link={{true}}
@replace={{true}}
data-test-edit-link
>
Edit role
</ToolbarSecretLink>
@ -43,28 +37,52 @@
</Toolbar>
{{/if}}
{{#if (or (eq this.mode "edit") (eq this.mode "create"))}}
<form onsubmit={{action "createOrUpdate" this.mode}}>
{{#if (or (eq @mode "edit") (eq @mode "create"))}}
<form {{on "submit" this.createOrUpdate}}>
<div class="box is-sideless is-fullwidth is-marginless">
<MessageError @model={{this.model}} />
<NamespaceReminder @mode={{this.mode}} @noun="Transform role" />
{{#each this.model.attrs as |attr|}}
{{#if (and (eq this.mode "edit") attr.options.readOnly)}}
<ReadonlyFormField @attr={{attr}} @value={{get this.model attr.name}} />
<NamespaceReminder @mode={{@mode}} @noun="transform role" />
<MessageError @errorMessage={{this.errorMessage}} />
{{#each @form.formFields as |field|}}
{{#if (eq field.name "transformations")}}
<div class="form-section">
<SearchSelect
@id="transformations"
@label={{field.options.label}}
@labelClass="title is-4"
@subText={{field.options.subText}}
@options={{this.transformations}}
@inputValue={{@form.data.transformations}}
@onChange={{fn (mut @form.transformations)}}
@disallowNewItems={{true}}
@fallbackComponent="string-list"
data-test-field
/>
{{#if (and this.modelValidations.transformations (not this.modelValidations.transformations.isValid))}}
<Hds::Form::Error data-test-validation-error="transformations">
{{this.modelValidations.transformations.errors}}
</Hds::Form::Error>
{{/if}}
</div>
{{else}}
<FormField data-test-field @attr={{attr}} @model={{this.model}} />
<FormField
data-test-field
@attr={{field}}
@model={{@form}}
@mode={{@mode}}
@modelValidations={{this.modelValidations}}
/>
{{/if}}
{{/each}}
</div>
<div class="field is-grouped-split box is-fullwidth is-bottomless">
<Hds::ButtonSet>
<Hds::Button @text={{if (eq this.mode "create") "Create role" "Save"}} type="submit" data-test-submit />
{{#if (eq this.mode "create")}}
<Hds::Button @text={{if (eq @mode "create") "Create role" "Save"}} type="submit" data-test-submit />
{{#if (eq @mode "create")}}
<Hds::Button
@text="Cancel"
@color="secondary"
@route="vault.cluster.secrets.backend.list-root"
@model={{this.model.backend}}
@model={{@form.data.backend}}
@query={{hash tab="role"}}
/>
{{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}}
</Hds::ButtonSet>
@ -81,25 +98,13 @@
</form>
{{else}}
<div class="box is-fullwidth is-sideless is-paddingless is-marginless">
{{#each this.model.attrs as |attr|}}
{{#if (eq attr.type "object")}}
<InfoTableRow
@label={{capitalize (or attr.options.label (humanize (dasherize attr.name)))}}
@value={{stringify (get this.model attr.name)}}
/>
{{else if (eq attr.type "array")}}
<InfoTableRow
@label={{capitalize (or attr.options.label (humanize (dasherize attr.name)))}}
@value={{get this.model attr.name}}
@type={{attr.type}}
@isLink={{eq attr.name "transformations"}}
/>
{{else}}
<InfoTableRow
@label={{capitalize (or attr.options.label (humanize (dasherize attr.name)))}}
@value={{get this.model attr.name}}
/>
{{/if}}
{{#each @form.formFields as |field|}}
<InfoTableRow
@label={{capitalize (or field.options.label (humanize (dasherize field.name)))}}
@value={{get @form.data field.name}}
@type={{field.type}}
@isLink={{eq field.name "transformations"}}
/>
{{/each}}
</div>
{{/if}}

View file

@ -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
* <TransformRoleEdit @form={{this.form}} @capabilities={{this.capabilities}} @mode={{this.mode}} />
* ```
* @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);
}
}
}

View file

@ -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
}}
<Page::Header @title={{this.title}} @subtitle={{this.subtitle}}>
<:breadcrumbs>
<KeyValueHeader
@baseKey={{this.model}}
@path="vault.cluster.secrets.backend.list"
@mode={{this.mode}}
@root={{this.breadcrumbs}}
@showCurrent={{true}}
/>
<Page::Breadcrumbs @breadcrumbs={{this.breadcrumbs}} />
</:breadcrumbs>
</Page::Header>
{{#if (eq this.mode "show")}}
{{#if (eq @mode "show")}}
<Toolbar>
<ToolbarActions>
{{#if this.model.updatePath.canDelete}}
{{#if (gt this.model.allowed_roles.length 0)}}
{{#if @capabilities.canDelete}}
{{#if (gt @form.data.allowed_roles.length 0)}}
<Hds::TooltipButton @text="This transformation is in use by a role and can't be deleted.">
<Hds::Button
@text="Delete transformation"
@ -34,22 +28,29 @@ SPDX-License-Identifier: BUSL-1.1
@text="Delete transformation"
@color="secondary"
class="toolbar-button"
{{on "click" (action (mut this.isDeleteModalActive) true)}}
data-test-delete
{{on "click" (fn (mut this.isDeleteModalActive) true)}}
/>
{{/if}}
<div class="toolbar-separator"></div>
{{/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)}}
<Hds::Button
@text="Edit transformation"
@color="secondary"
class="toolbar-button"
{{on "click" (fn (mut this.isEditModalActive) true)}}
data-test-edit-link
{{on "click" (fn (mut this.isEditModalActive) true)}}
/>
{{else}}
<ToolbarSecretLink @secret={{this.model.id}} @mode="edit" data-test-edit-link={{true}} @replace={{true}}>
<ToolbarSecretLink
@secret={{@form.data.name}}
@backend={{@form.data.backend}}
@mode="edit"
@replace={{true}}
data-test-edit-link
>
Edit transformation
</ToolbarSecretLink>
{{/if}}
@ -58,29 +59,162 @@ SPDX-License-Identifier: BUSL-1.1
</Toolbar>
{{/if}}
{{#if (eq this.mode "edit")}}
<TransformEditForm @mode={{this.mode}} @model={{this.model}} />
{{else if (eq this.mode "create")}}
<TransformCreateForm @mode={{this.mode}} @model={{this.model}} />
{{#if (or (eq @mode "edit") (eq @mode "create"))}}
<form {{on "submit" this.createOrUpdate}}>
<div class="box is-sideless is-fullwidth is-marginless">
<NamespaceReminder @mode={{@mode}} @noun="transformation" />
<MessageError @errorMessage={{this.errorMessage}} />
{{#each this.visibleFormFields as |field|}}
{{#if (eq field.name "template")}}
<div class="form-section">
<SearchSelect
@id="template"
@label={{field.options.label}}
@labelClass="title is-4"
@subText={{field.options.subText}}
@options={{this.templates}}
@inputValue={{@form.data.template}}
@onChange={{fn (mut @form.template)}}
@selectLimit={{1}}
@disallowNewItems={{true}}
@fallbackComponent="string-list"
data-test-field
/>
{{#if (and this.modelValidations.template (not this.modelValidations.template.isValid))}}
<Hds::Form::Error data-test-validation-error="template">
{{this.modelValidations.template.errors}}
</Hds::Form::Error>
{{/if}}
</div>
{{else if (eq field.name "allowed_roles")}}
<div class="form-section">
<SearchSelect
@id="allowed_roles"
@label={{field.options.label}}
@labelClass="title is-4"
@subText={{field.options.subText}}
@options={{this.roles}}
@inputValue={{@form.data.allowed_roles}}
@onChange={{fn (mut @form.allowed_roles)}}
@fallbackComponent="string-list"
@wildcardLabel="role"
data-test-field
/>
</div>
{{else}}
<FormField
data-test-field
@attr={{field}}
@model={{@form}}
@mode={{@mode}}
@modelValidations={{this.modelValidations}}
/>
{{/if}}
{{/each}}
</div>
<div class="field is-grouped-split box is-fullwidth is-bottomless">
<Hds::ButtonSet>
<Hds::Button @text={{if (eq @mode "create") "Create transformation" "Save"}} type="submit" data-test-submit />
{{#if (eq @mode "create")}}
<Hds::Button
@text="Cancel"
@color="secondary"
@route="vault.cluster.secrets.backend.list-root"
@model={{@form.data.backend}}
@query={{hash tab="transformations"}}
/>
{{else}}
<Hds::Button
@text="Cancel"
@color="secondary"
@route="vault.cluster.secrets.backend.show"
@models={{array @form.data.backend @form.data.name}}
/>
{{/if}}
</Hds::ButtonSet>
</div>
</form>
{{else}}
<TransformShowTransformation @model={{this.model}} @transformRoles={{this.transformRoles}} />
<div class="box is-fullwidth is-sideless is-paddingless is-marginless">
{{#each this.visibleFormFields as |field|}}
{{#if (eq field.name "allowed_roles")}}
<InfoTableRow
@label={{capitalize (or field.options.label (humanize (dasherize field.name)))}}
@value={{get @form.data field.name}}
@type="array"
@isLink={{true}}
@queryParam="role"
@wildcardLabel="role"
/>
{{else}}
<InfoTableRow
@label={{capitalize (or field.options.label (humanize (dasherize field.name)))}}
@value={{get @form.data field.name}}
@type={{field.type}}
/>
{{/if}}
{{/each}}
</div>
<div class="has-top-margin-xl has-bottom-margin-s">
<label class="title has-border-bottom-light page-header">CLI Commands</label>
<div class="has-bottom-margin-s">
<h2 class="title is-6">Encode</h2>
<div class="has-bottom-margin-s">
<span class="helper-text has-text-grey">
To test the encoding capability of your transformation, use the following command. It will output an encoded_value.
</span>
</div>
<div class="copy-text level">
{{#let (concat "vault write " @form.data.backend "/encode/" this.cliCommand) as |copyEncodeCommand|}}
<Hds::CodeBlock
@language="bash"
@hasLineNumbers={{false}}
@hasLineWrapping={{true}}
@hasCopyButton={{true}}
@value={{copyEncodeCommand}}
/>
{{/let}}
</div>
</div>
<div>
<h2 class="title is-6">Decode</h2>
<div class="has-bottom-margin-s">
<span class="helper-text has-text-grey">
To test decoding capability of your transformation, use the encoded_value in the following command. It should
return your original input.
</span>
</div>
<div class="copy-text level">
{{#let (concat "vault write " @form.data.backend "/decode/" this.cliCommand) as |copyDecodeCommand|}}
<Hds::CodeBlock
@language="bash"
@hasLineNumbers={{false}}
@hasLineWrapping={{true}}
@hasCopyButton={{true}}
@value={{copyDecodeCommand}}
/>
{{/let}}
</div>
</div>
</div>
{{/if}}
<ConfirmationModal
@title="Delete transformation"
@onClose={{action (mut this.isDeleteModalActive) false}}
@onClose={{fn (mut this.isDeleteModalActive) false}}
@isActive={{this.isDeleteModalActive}}
@confirmText={{this.model.name}}
@confirmText={{@form.data.name}}
@toConfirmMsg="deleting the transformation."
@onConfirm={{action "delete"}}
@onConfirm={{this.onDelete}}
>
<p class="has-bottom-margin-m">
Deleting the
<strong>{{this.model.name}}</strong>
<strong>{{@form.data.name}}</strong>
transformation means that the underlying keys are lost and the data encoded by the transformation are unrecoverable and
cannot be decoded.
</p>
<MessageError @model={{this.model}} @errorMessage={{this.error}} />
<MessageError @errorMessage={{this.errorMessage}} />
</ConfirmationModal>
{{#if this.isEditModalActive}}
@ -96,12 +230,7 @@ SPDX-License-Identifier: BUSL-1.1
</M.Body>
<M.Footer as |F|>
<Hds::ButtonSet>
<Hds::Button
@text="Confirm"
@route="vault.cluster.secrets.backend.edit"
@model={{this.model.id}}
data-test-edit-confirm-button
/>
<Hds::Button @text="Confirm" data-test-edit-confirm-button {{on "click" this.confirmEdit}} />
<Hds::Button @color="secondary" @text="Cancel" {{on "click" F.close}} />
</Hds::ButtonSet>
</M.Footer>

View file

@ -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
* <TransformationEdit @form={{this.form}} @capabilities={{this.capabilities}} @mode={{this.mode}} />
* ```
* @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 = '<choose a role>';
if (rolesArr.length === 1 && !wildCardRole) {
role = rolesArr[0];
}
let tweak = '';
if (type === 'fpe' && tweak_source === 'supplied') {
tweak = 'tweak=<enter your tweak>';
}
return `${role} value=<enter your value here> ${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');
}
}

View file

@ -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<RoleData> {
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.' }],
};
}

View file

@ -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<TransformationData> {
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.' }],
};
}

View file

@ -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';

View file

@ -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,
});
},

View file

@ -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');
});
});

View file

@ -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`
<TransformRoleEdit />
`);
assert.dom(this.element).hasText('template block text');
});
});

View file

@ -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<string, unknown>;
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`<TransformRoleEdit @form={{this.form}} @capabilities={{this.capabilities}} @mode={{this.mode}} />`
);
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`<TransformRoleEdit @form={{this.form}} @capabilities={{this.capabilities}} @mode={{this.mode}} />`
);
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`<TransformRoleEdit @form={{this.form}} @capabilities={{this.capabilities}} @mode={{this.mode}} />`
);
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`<TransformRoleEdit @form={{this.form}} @capabilities={{this.capabilities}} @mode={{this.mode}} />`
);
await click('[data-test-delete]');
assert.ok(deleteStub.calledWith('my-role', 'transform'), 'calls transformDeleteRole with correct args');
deleteStub.restore();
});
});