[UI] Ember Data Migration - SSH Role Sign Key and Generate Credential Views | Vault-45234 (#15100) (#15107)

* migrated ssh views - list, detail, create and edit

* adds validation for role name and update test attributes for consistency

* updated sign key attr name in test

* migrated ssh views - list, detail, create and edit

* adds validation for role name and update test attributes for consistency

* updated sign key attr name in test

* moved flat ordering logic to form as per dynamic selection

* Humanized TTL field display value

* Apply suggestions from code review



* fixed prettier issue

* VAULT-45234 - Migrates SSH credential generation and signing components with forms and Api service

* fixed review comments

* Apply suggestions from code review



---------

Co-authored-by: mohit-hashicorp <mohit.ojha@hashicorp.com>
Co-authored-by: Kianna <30884335+kiannaquach@users.noreply.github.com>
This commit is contained in:
Vault Automation 2026-06-01 13:15:48 -06:00 committed by GitHub
parent 65e0793e46
commit 5a7eb39077
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 433 additions and 151 deletions

View file

@ -0,0 +1,77 @@
{{!
Copyright IBM Corp. 2016, 2026
SPDX-License-Identifier: BUSL-1.1
}}
<Page::Header @title="Generate SSH Credentials">
<:breadcrumbs>
<Page::Breadcrumbs @breadcrumbs={{this.breadcrumbs}} />
</:breadcrumbs>
</Page::Header>
{{#if this.otpData}}
<div class="box is-fullwidth is-sideless is-paddingless is-marginless">
<Hds::Alert @type="inline" @color="warning" class="has-top-bottom-margin" data-test-warning as |A|>
<A.Title>Warning</A.Title>
<A.Description>
You will not be able to access this information later, so please copy the information below.
</A.Description>
</Hds::Alert>
{{#each this.otpDisplayRows as |row|}}
{{#if row.masked}}
<InfoTableRow @label={{row.label}} @value={{row.value}}>
<MaskedInput @value={{row.value}} @name="key" @displayOnly={{true}} @allowCopy={{true}} />
</InfoTableRow>
{{else}}
<InfoTableRow @label={{row.label}} @value={{row.value}} />
{{/if}}
{{/each}}
</div>
<div class="field is-grouped box is-fullwidth is-bottomless">
<div class="control">
<Hds::Copy::Button
@text="Copy credentials"
@textToCopy={{this.otpData.key}}
@onError={{(fn (set-flash-message "Clipboard copy failed. The Clipboard API requires a secure context." "danger"))}}
class="primary"
/>
</div>
<div class="control">
<Hds::Button @text="Back" @color="secondary" {{on "click" this.reset}} data-test-back-button />
</div>
</div>
{{else}}
<form {{on "submit" (perform this.generate)}} data-test-secret-generate-form>
<div class="box is-sideless is-fullwidth is-marginless">
<NamespaceReminder @mode="generate" @noun="credential" />
<MessageError @errorMessage={{this.errorMessage}} />
{{#each this.credentialForm.formFields as |attr|}}
<FormField
data-test-field
@attr={{attr}}
@model={{this.credentialForm}}
@modelValidations={{this.modelValidations}}
/>
{{/each}}
{{#if this.invalidFormAlert}}
<AlertInline @type="danger" @message={{this.invalidFormAlert}} class="has-top-padding-s" />
{{/if}}
</div>
<Hds::ButtonSet class="has-top-bottom-margin">
<Hds::Button
@text="Generate"
@icon={{if this.generate.isRunning "loading"}}
type="submit"
disabled={{this.generate.isRunning}}
data-test-submit
/>
<Hds::Button
@text="Cancel"
@route="vault.cluster.secrets.backend.list-root"
@color="secondary"
@model={{@backendPath}}
data-test-cancel
/>
</Hds::ButtonSet>
</form>
{{/if}}

View file

@ -0,0 +1,90 @@
/**
* Copyright IBM Corp. 2016, 2026
* SPDX-License-Identifier: BUSL-1.1
*/
import Component from '@glimmer/component';
import { service } from '@ember/service';
import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking';
import { task } from 'ember-concurrency';
import { waitFor } from '@ember/test-waiters';
import SshOtpCredentialForm from 'vault/forms/ssh/otp-credential';
import type ApiService from 'vault/services/api';
import type ControlGroupService from 'vault/vault/services/control-group';
interface Args {
backendPath: string;
roleName: string;
}
export default class GenerateCredentialsSsh extends Component<Args> {
@service declare readonly api: ApiService;
@service declare readonly controlGroup: ControlGroupService;
@tracked credentialForm = new SshOtpCredentialForm();
@tracked otpData: Record<string, unknown> | null = null;
@tracked errorMessage: string | null = null;
@tracked modelValidations: Record<string, unknown> | null = null;
@tracked invalidFormAlert: string | null = null;
get otpDisplayRows() {
const data = this.otpData;
if (!data) return [];
return [
{ label: 'Username', value: data['username'] },
{ label: 'IP Address', value: data['ip'] },
{ label: 'Key', value: data['key'], masked: true },
{ label: 'Key type', value: data['key_type'] },
{ label: 'Port', value: data['port'] },
].filter((f) => f.value != null && f.value !== '');
}
get breadcrumbs() {
const { backendPath, roleName } = this.args;
return [
{ label: backendPath, route: 'vault.cluster.secrets.backend', model: backendPath },
{ label: 'Credentials', route: 'vault.cluster.secrets.backend', model: backendPath },
{ label: roleName, route: 'vault.cluster.secrets.backend.show', model: roleName },
{ label: 'Generate SSH credentials' },
];
}
generate = task(
waitFor(async (evt: Event) => {
evt.preventDefault();
this.errorMessage = null;
const { isValid, state, invalidFormMessage, data } = this.credentialForm.toJSON();
this.modelValidations = isValid ? null : state;
this.invalidFormAlert = isValid ? null : invalidFormMessage;
if (!isValid) return;
try {
const result = await this.api.secrets.sshGenerateCredentials(
this.args.roleName,
this.args.backendPath,
data
);
this.otpData = (result.data as Record<string, unknown>) ?? {};
} catch (error) {
const { message, response } = await this.api.parseError(error);
if (response?.isControlGroupError) {
this.controlGroup.saveTokenFromError(response);
this.errorMessage = this.controlGroup.logFromError(response).content;
} else {
this.errorMessage = message;
}
}
})
);
@action
reset() {
this.otpData = null;
this.credentialForm = new SshOtpCredentialForm();
this.errorMessage = null;
this.modelValidations = null;
this.invalidFormAlert = null;
}
}

View file

@ -0,0 +1,81 @@
{{!
Copyright IBM Corp. 2016, 2026
SPDX-License-Identifier: BUSL-1.1
}}
<Page::Header @title="Sign SSH key">
<:breadcrumbs>
<Page::Breadcrumbs @breadcrumbs={{this.breadcrumbs}} />
</:breadcrumbs>
</Page::Header>
{{#if this.signedKeyData}}
<div class="box is-fullwidth is-sideless is-paddingless is-marginless">
<Hds::Alert @type="inline" @color="warning" class="has-top-margin-s has-bottom-margin-s" as |A|>
<A.Title>Warning</A.Title>
<A.Description>
You will not be able to access this information later, so please copy the information below.
</A.Description>
</Hds::Alert>
{{#each this.signDisplayRows as |row|}}
<InfoTableRow @label={{row.label}} @value={{row.value}} />
{{/each}}
</div>
<div class="field is-grouped box is-fullwidth is-bottomless">
<div class="control">
<Hds::Copy::Button
@text="Copy key"
@textToCopy={{this.signedKeyData.signed_key}}
@onError={{(fn (set-flash-message "Clipboard copy failed. The Clipboard API requires a secure context." "danger"))}}
class="primary"
/>
</div>
{{#if this.signedKeyData.lease_id}}
<div class="control">
<Hds::Copy::Button
@text="Copy lease ID"
@textToCopy={{this.signedKeyData.lease_id}}
@onError={{(fn
(set-flash-message "Clipboard copy failed. The Clipboard API requires a secure context." "danger")
)}}
class="secondary"
/>
</div>
{{/if}}
<div class="control">
<Hds::Button @text="Back" @color="secondary" {{on "click" this.reset}} data-test-back-button />
</div>
</div>
{{else}}
<form {{on "submit" (perform this.sign)}} data-test-secret-generate-form="true">
<div class="box is-sideless is-fullwidth is-marginless">
<MessageError @errorMessage={{this.errorMessage}} />
<NamespaceReminder @mode="sign" @noun="SSH key" />
<FormFieldGroups
@model={{this.signForm}}
@mode="create"
@groupName="formFieldGroups"
@modelValidations={{this.modelValidations}}
/>
{{#if this.invalidFormAlert}}
<AlertInline @type="danger" @message={{this.invalidFormAlert}} class="has-top-padding-s" />
{{/if}}
</div>
<Hds::ButtonSet class="has-top-bottom-margin">
<Hds::Button
@text="Sign"
@icon={{if this.sign.isRunning "loading"}}
type="submit"
disabled={{this.sign.isRunning}}
data-test-submit
/>
<Hds::Button
@text="Cancel"
@color="secondary"
@route="vault.cluster.secrets.backend.list-root"
@model={{@backendPath}}
data-test-cancel
/>
</Hds::ButtonSet>
</form>
{{/if}}

View file

@ -0,0 +1,90 @@
/**
* Copyright IBM Corp. 2016, 2026
* SPDX-License-Identifier: BUSL-1.1
*/
import Component from '@glimmer/component';
import { service } from '@ember/service';
import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking';
import { task } from 'ember-concurrency';
import { waitFor } from '@ember/test-waiters';
import SshSignForm from 'vault/forms/ssh/sign';
import type ApiService from 'vault/services/api';
import type ControlGroupService from 'vault/vault/services/control-group';
interface Args {
roleName: string;
backendPath: string;
}
export default class SshSignKey extends Component<Args> {
@service declare readonly api: ApiService;
@service declare readonly controlGroup: ControlGroupService;
@tracked signForm = new SshSignForm({ cert_type: 'user' });
@tracked signedKeyData: Record<string, unknown> | null = null;
@tracked errorMessage: string | null = null;
@tracked modelValidations: Record<string, unknown> | null = null;
@tracked invalidFormAlert: string | null = null;
get signDisplayRows() {
const data = this.signedKeyData;
if (!data) return [];
return [
{ label: 'Signed key', value: data['signed_key'] },
{ label: 'Lease ID', value: data['lease_id'] },
{ label: 'Renewable', value: data['renewable'] },
{ label: 'Lease duration', value: data['lease_duration'] },
{ label: 'Serial number', value: data['serial_number'] },
].filter((f) => f.value != null && f.value !== '');
}
get breadcrumbs() {
const { backendPath, roleName } = this.args;
return [
{ label: backendPath, route: 'vault.cluster.secrets.backend', model: backendPath },
{ label: 'Sign', route: 'vault.cluster.secrets.backend', model: backendPath },
{ label: roleName, route: 'vault.cluster.secrets.backend.show', model: roleName },
{ label: 'Sign SSH Key' },
];
}
sign = task(
waitFor(async (evt: Event) => {
evt.preventDefault();
this.errorMessage = null;
const { isValid, state, invalidFormMessage, data } = this.signForm.toJSON();
this.modelValidations = isValid ? null : state;
this.invalidFormAlert = isValid ? null : invalidFormMessage;
if (!isValid) return;
try {
const result = await this.api.secrets.sshSignCertificate(
this.args.roleName,
this.args.backendPath,
data
);
this.signedKeyData = { ...result, ...(result.data as Record<string, unknown>) };
} catch (error) {
const { message, response } = await this.api.parseError(error);
if (response?.isControlGroupError) {
this.controlGroup.saveTokenFromError(response);
this.errorMessage = this.controlGroup.logFromError(response).content;
} else {
this.errorMessage = message;
}
}
})
);
@action
reset() {
this.signedKeyData = null;
this.signForm = new SshSignForm({ cert_type: 'user' });
this.errorMessage = null;
this.modelValidations = null;
this.invalidFormAlert = null;
}
}

View file

@ -0,0 +1,24 @@
/**
* 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 { Validations } from 'vault/vault/app-types';
interface SshOtpCredentialData {
username: string;
ip: string;
}
export default class SshOtpCredentialForm extends Form<SshOtpCredentialData> {
formFields = [
new FormField('username', 'string', { label: 'Username' }),
new FormField('ip', 'string', { label: 'IP address' }),
];
validations: Validations = {
ip: [{ type: 'presence', message: 'IP address is required' }],
};
}

49
ui/app/forms/ssh/sign.ts Normal file
View file

@ -0,0 +1,49 @@
/**
* 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 FormFieldGroup from 'vault/utils/forms/field-group';
import { Validations } from 'vault/vault/app-types';
interface SshSignData {
public_key: string;
key_id?: string;
valid_principals?: string;
cert_type?: string;
critical_options?: Record<string, unknown>;
extensions?: Record<string, unknown>;
ttl?: string;
}
export default class SshSignForm extends Form<SshSignData> {
get formFieldGroups() {
return [
new FormFieldGroup('default', [
new FormField('public_key', 'string', { label: 'Public key' }),
new FormField('valid_principals', 'string', {
label: 'Valid principals',
helpText:
'Specifies valid principals, either usernames or hostnames, that the certificate should be signed for. Required unless the role has specified allow_empty_principals.',
}),
]),
new FormFieldGroup('More options', [
new FormField('key_id', 'string', { label: 'Key ID' }),
new FormField('cert_type', 'string', {
label: 'Certificate Type',
possibleValues: ['user', 'host'],
defaultValue: 'user',
}),
new FormField('critical_options', 'object', { label: 'Critical Options' }),
new FormField('extensions', 'object', { label: 'Extensions' }),
new FormField('ttl', 'string', { label: 'TTL', editType: 'ttl' }),
]),
];
}
validations: Validations = {
public_key: [{ type: 'presence', message: 'Public Key is required' }],
};
}

View file

@ -20,10 +20,6 @@ export default Route.extend({
if (!SUPPORTED_DYNAMIC_BACKENDS.includes(backendType)) {
return this.router.transitionTo('vault.cluster.secrets.backend.list-root', backendPath);
}
// hydrate model if backend type is ssh
if (backendType === 'ssh') {
this.pathHelp.hydrateModel('ssh-otp-credential', backendPath);
}
// assign back button route
if (backendType === 'totp') {

View file

@ -1,32 +1,21 @@
/**
* Copyright IBM Corp. 2016, 2025
* Copyright IBM Corp. 2016, 2026
* SPDX-License-Identifier: BUSL-1.1
*/
import Route from '@ember/routing/route';
import UnloadModel from 'vault/mixins/unload-model-route';
import { service } from '@ember/service';
export default Route.extend(UnloadModel, {
export default Route.extend({
router: service(),
store: service(),
capabilities: service(),
templateName: 'vault/cluster/secrets/backend/sign',
backendModel() {
return this.modelFor('vault.cluster.secrets.backend');
},
pathQuery(role, backend) {
return {
id: `${backend}/sign/${role}`,
};
},
pathForType() {
return 'sign';
},
model(params) {
async model(params) {
const role = params.secret;
const backendModel = this.backendModel();
const backend = backendModel.id;
@ -34,23 +23,16 @@ export default Route.extend(UnloadModel, {
if (backendModel.type !== 'ssh') {
return this.router.transitionTo('vault.cluster.secrets.backend.list-root', backend);
}
return this.store.queryRecord('capabilities', this.pathQuery(role, backend)).then((capabilities) => {
if (!capabilities.canUpdate) {
return this.router.transitionTo('vault.cluster.secrets.backend.list-root', backend);
}
return this.store.createRecord('ssh-sign', {
role: {
backend,
id: role,
name: role,
},
id: `${backend}-${role}`,
});
});
},
setupController(controller) {
this._super(...arguments);
controller.set('backend', this.backendModel());
const signPath = this.capabilities.pathFor('sshSign', { backend, id: role });
const capabilities = await this.capabilities.fetch([signPath]);
if (!capabilities[signPath]?.canUpdate) {
return this.router.transitionTo('vault.cluster.secrets.backend.list-root', backend);
}
return {
roleName: role,
backendPath: backend,
};
},
});

View file

@ -1,5 +1,5 @@
{{!
Copyright IBM Corp. 2016, 2025
Copyright IBM Corp. 2016, 2026
SPDX-License-Identifier: BUSL-1.1
}}
@ -18,6 +18,8 @@
@keyName={{this.model.keyName}}
@totpCodePeriod={{this.model.totpCodePeriod}}
/>
{{else if (eq this.model.backendType "ssh")}}
<GenerateCredentialsSsh @backendPath={{this.model.backendPath}} @roleName={{this.model.roleName}} />
{{else}}
<GenerateCredentials
@backendPath={{this.model.backendPath}}

View file

@ -1,115 +1,6 @@
{{!
Copyright IBM Corp. 2016, 2025
Copyright IBM Corp. 2016, 2026
SPDX-License-Identifier: BUSL-1.1
}}
<Page::Header @title="Sign SSH Key">
<:breadcrumbs>
<Page::Breadcrumbs
@breadcrumbs={{array
(hash label="SSH" route="vault.cluster.secrets.backend" model=this.backend.id)
(hash label="Sign" route="vault.cluster.secrets.backend" model=this.backend.id)
(hash label=this.model.role.name route="vault.cluster.secrets.backend.show" model=this.model.role.name)
(hash label="Sign SSH Key")
}}
/>
</:breadcrumbs>
</Page::Header>
{{#if this.model.signedKey}}
<div class="box is-fullwidth is-sideless is-paddingless is-marginless">
<Hds::Alert @type="inline" @color="warning" class="has-top-margin-s has-bottom-margin-s" as |A|>
<A.Title>Warning</A.Title>
<A.Description>
You will not be able to access this information later, so please copy the information below.
</A.Description>
</Hds::Alert>
{{#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}}
<InfoTableRow
@label={{capitalize (or attr.options.label (humanize (dasherize attr.name)))}}
@value={{get this.model attr.name}}
/>
{{/if}}
{{/each}}
</div>
<div class="field is-grouped box is-fullwidth is-bottomless">
<div class="control">
<Hds::Copy::Button
@text="Copy key"
@textToCopy={{this.model.signedKey}}
@onError={{(fn (set-flash-message "Clipboard copy failed. The Clipboard API requires a secure context." "danger"))}}
class="primary"
/>
</div>
{{#if this.model.leaseId}}
<div class="control">
<Hds::Copy::Button
@text="Copy lease ID"
@textToCopy={{this.model.leaseId}}
@onError={{(fn
(set-flash-message "Clipboard copy failed. The Clipboard API requires a secure context." "danger")
)}}
class="secondary"
/>
</div>
{{/if}}
<div class="control">
<Hds::Button @text="Back" @color="secondary" {{on "click" (action "newModel")}} data-test-back-button />
</div>
</div>
{{else}}
<form {{action "sign" on="submit"}} data-test-secret-generate-form="true">
<div class="box is-sideless is-fullwidth is-marginless">
<MessageError @model={{this.model}} />
<NamespaceReminder @mode="sign" @noun="SSH key" />
{{#if this.model.attrs}}
{{#let (find-by "name" "publicKey" this.model.attrs) as |attr|}}
<FormFieldFromModel @attr={{attr}} @model={{this.model}} />
{{/let}}
{{! valid_principals is required unless allow_empty_principals is true (not recommended) }}
{{#let (find-by "name" "validPrincipals" this.model.attrs) as |attr|}}
<FormFieldFromModel @attr={{attr}} @model={{this.model}} />
{{/let}}
<ToggleButton @isOpen={{this.showOptions}} @onClick={{fn (mut this.showOptions)}} data-test-toggle-button />
{{#if this.showOptions}}
<div class="box is-marginless">
{{#each this.model.attrs as |attr|}}
{{! These attrs render above, outside of the "More options" toggle }}
{{#if (not (includes attr.name (array "publicKey" "validPrincipals")))}}
<FormFieldFromModel
@attr={{attr}}
@model={{this.model}}
@updateTtl={{action "updateTtl" attr.name}}
@emptyData={{this.emptyData}}
@editorUpdated={{action "editorUpdated" attr.name}}
/>
{{/if}}
{{/each}}
</div>
{{/if}}
{{/if}}
</div>
<Hds::ButtonSet class="has-top-bottom-margin">
<Hds::Button
@text="Sign"
@icon={{if this.loading "loading"}}
type="submit"
disabled={{this.loading}}
data-test-submit
/>
<Hds::Button
@text="Cancel"
@color="secondary"
@route="vault.cluster.secrets.backend.list-root"
@model={{this.backend.id}}
data-test-cancel
/>
</Hds::ButtonSet>
</form>
{{/if}}
<SshSignKey @roleName={{this.model.roleName}} @backendPath={{this.model.backendPath}} />

View file

@ -1,5 +1,5 @@
/**
* Copyright IBM Corp. 2016, 2025
* Copyright IBM Corp. 2016, 2026
* SPDX-License-Identifier: BUSL-1.1
*/
@ -52,8 +52,8 @@ module('Acceptance | ssh | roles', function (hooks) {
await click(GENERAL.inputByAttr('allow_empty_principals'));
},
async fillInGenerate() {
await fillIn(GENERAL.inputByAttr('publicKey'), PUB_KEY);
await click('[data-test-toggle-button]');
await fillIn(GENERAL.inputByAttr('public_key'), PUB_KEY);
await click(GENERAL.button('More options'));
await click(GENERAL.ttl.toggle('TTL'));
await fillIn(GENERAL.selectByAttr('ttl-unit'), 'm');