-
- {{#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"))}}
+
{{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();
+ });
+});