Skip to content

Commit 5a7eb39

Browse files
hc-github-team-secure-vault-coremohit-hashicorpkiannaquach
authored
[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>
1 parent 65e0793 commit 5a7eb39

11 files changed

Lines changed: 433 additions & 151 deletions

File tree

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
{{!
2+
Copyright IBM Corp. 2016, 2026
3+
SPDX-License-Identifier: BUSL-1.1
4+
}}
5+
6+
<Page::Header @title="Generate SSH Credentials">
7+
<:breadcrumbs>
8+
<Page::Breadcrumbs @breadcrumbs={{this.breadcrumbs}} />
9+
</:breadcrumbs>
10+
</Page::Header>
11+
12+
{{#if this.otpData}}
13+
<div class="box is-fullwidth is-sideless is-paddingless is-marginless">
14+
<Hds::Alert @type="inline" @color="warning" class="has-top-bottom-margin" data-test-warning as |A|>
15+
<A.Title>Warning</A.Title>
16+
<A.Description>
17+
You will not be able to access this information later, so please copy the information below.
18+
</A.Description>
19+
</Hds::Alert>
20+
{{#each this.otpDisplayRows as |row|}}
21+
{{#if row.masked}}
22+
<InfoTableRow @label={{row.label}} @value={{row.value}}>
23+
<MaskedInput @value={{row.value}} @name="key" @displayOnly={{true}} @allowCopy={{true}} />
24+
</InfoTableRow>
25+
{{else}}
26+
<InfoTableRow @label={{row.label}} @value={{row.value}} />
27+
{{/if}}
28+
{{/each}}
29+
</div>
30+
<div class="field is-grouped box is-fullwidth is-bottomless">
31+
<div class="control">
32+
<Hds::Copy::Button
33+
@text="Copy credentials"
34+
@textToCopy={{this.otpData.key}}
35+
@onError={{(fn (set-flash-message "Clipboard copy failed. The Clipboard API requires a secure context." "danger"))}}
36+
class="primary"
37+
/>
38+
</div>
39+
<div class="control">
40+
<Hds::Button @text="Back" @color="secondary" {{on "click" this.reset}} data-test-back-button />
41+
</div>
42+
</div>
43+
{{else}}
44+
<form {{on "submit" (perform this.generate)}} data-test-secret-generate-form>
45+
<div class="box is-sideless is-fullwidth is-marginless">
46+
<NamespaceReminder @mode="generate" @noun="credential" />
47+
<MessageError @errorMessage={{this.errorMessage}} />
48+
{{#each this.credentialForm.formFields as |attr|}}
49+
<FormField
50+
data-test-field
51+
@attr={{attr}}
52+
@model={{this.credentialForm}}
53+
@modelValidations={{this.modelValidations}}
54+
/>
55+
{{/each}}
56+
{{#if this.invalidFormAlert}}
57+
<AlertInline @type="danger" @message={{this.invalidFormAlert}} class="has-top-padding-s" />
58+
{{/if}}
59+
</div>
60+
<Hds::ButtonSet class="has-top-bottom-margin">
61+
<Hds::Button
62+
@text="Generate"
63+
@icon={{if this.generate.isRunning "loading"}}
64+
type="submit"
65+
disabled={{this.generate.isRunning}}
66+
data-test-submit
67+
/>
68+
<Hds::Button
69+
@text="Cancel"
70+
@route="vault.cluster.secrets.backend.list-root"
71+
@color="secondary"
72+
@model={{@backendPath}}
73+
data-test-cancel
74+
/>
75+
</Hds::ButtonSet>
76+
</form>
77+
{{/if}}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
/**
2+
* Copyright IBM Corp. 2016, 2026
3+
* SPDX-License-Identifier: BUSL-1.1
4+
*/
5+
6+
import Component from '@glimmer/component';
7+
import { service } from '@ember/service';
8+
import { action } from '@ember/object';
9+
import { tracked } from '@glimmer/tracking';
10+
import { task } from 'ember-concurrency';
11+
import { waitFor } from '@ember/test-waiters';
12+
import SshOtpCredentialForm from 'vault/forms/ssh/otp-credential';
13+
14+
import type ApiService from 'vault/services/api';
15+
import type ControlGroupService from 'vault/vault/services/control-group';
16+
17+
interface Args {
18+
backendPath: string;
19+
roleName: string;
20+
}
21+
22+
export default class GenerateCredentialsSsh extends Component<Args> {
23+
@service declare readonly api: ApiService;
24+
@service declare readonly controlGroup: ControlGroupService;
25+
26+
@tracked credentialForm = new SshOtpCredentialForm();
27+
@tracked otpData: Record<string, unknown> | null = null;
28+
@tracked errorMessage: string | null = null;
29+
@tracked modelValidations: Record<string, unknown> | null = null;
30+
@tracked invalidFormAlert: string | null = null;
31+
32+
get otpDisplayRows() {
33+
const data = this.otpData;
34+
if (!data) return [];
35+
return [
36+
{ label: 'Username', value: data['username'] },
37+
{ label: 'IP Address', value: data['ip'] },
38+
{ label: 'Key', value: data['key'], masked: true },
39+
{ label: 'Key type', value: data['key_type'] },
40+
{ label: 'Port', value: data['port'] },
41+
].filter((f) => f.value != null && f.value !== '');
42+
}
43+
44+
get breadcrumbs() {
45+
const { backendPath, roleName } = this.args;
46+
return [
47+
{ label: backendPath, route: 'vault.cluster.secrets.backend', model: backendPath },
48+
{ label: 'Credentials', route: 'vault.cluster.secrets.backend', model: backendPath },
49+
{ label: roleName, route: 'vault.cluster.secrets.backend.show', model: roleName },
50+
{ label: 'Generate SSH credentials' },
51+
];
52+
}
53+
54+
generate = task(
55+
waitFor(async (evt: Event) => {
56+
evt.preventDefault();
57+
this.errorMessage = null;
58+
59+
const { isValid, state, invalidFormMessage, data } = this.credentialForm.toJSON();
60+
this.modelValidations = isValid ? null : state;
61+
this.invalidFormAlert = isValid ? null : invalidFormMessage;
62+
if (!isValid) return;
63+
try {
64+
const result = await this.api.secrets.sshGenerateCredentials(
65+
this.args.roleName,
66+
this.args.backendPath,
67+
data
68+
);
69+
this.otpData = (result.data as Record<string, unknown>) ?? {};
70+
} catch (error) {
71+
const { message, response } = await this.api.parseError(error);
72+
if (response?.isControlGroupError) {
73+
this.controlGroup.saveTokenFromError(response);
74+
this.errorMessage = this.controlGroup.logFromError(response).content;
75+
} else {
76+
this.errorMessage = message;
77+
}
78+
}
79+
})
80+
);
81+
82+
@action
83+
reset() {
84+
this.otpData = null;
85+
this.credentialForm = new SshOtpCredentialForm();
86+
this.errorMessage = null;
87+
this.modelValidations = null;
88+
this.invalidFormAlert = null;
89+
}
90+
}

ui/app/components/ssh-sign-key.hbs

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
{{!
2+
Copyright IBM Corp. 2016, 2026
3+
SPDX-License-Identifier: BUSL-1.1
4+
}}
5+
6+
<Page::Header @title="Sign SSH key">
7+
<:breadcrumbs>
8+
<Page::Breadcrumbs @breadcrumbs={{this.breadcrumbs}} />
9+
</:breadcrumbs>
10+
</Page::Header>
11+
12+
{{#if this.signedKeyData}}
13+
<div class="box is-fullwidth is-sideless is-paddingless is-marginless">
14+
<Hds::Alert @type="inline" @color="warning" class="has-top-margin-s has-bottom-margin-s" as |A|>
15+
<A.Title>Warning</A.Title>
16+
<A.Description>
17+
You will not be able to access this information later, so please copy the information below.
18+
</A.Description>
19+
</Hds::Alert>
20+
{{#each this.signDisplayRows as |row|}}
21+
<InfoTableRow @label={{row.label}} @value={{row.value}} />
22+
{{/each}}
23+
</div>
24+
<div class="field is-grouped box is-fullwidth is-bottomless">
25+
<div class="control">
26+
<Hds::Copy::Button
27+
@text="Copy key"
28+
@textToCopy={{this.signedKeyData.signed_key}}
29+
@onError={{(fn (set-flash-message "Clipboard copy failed. The Clipboard API requires a secure context." "danger"))}}
30+
class="primary"
31+
/>
32+
</div>
33+
{{#if this.signedKeyData.lease_id}}
34+
<div class="control">
35+
<Hds::Copy::Button
36+
@text="Copy lease ID"
37+
@textToCopy={{this.signedKeyData.lease_id}}
38+
@onError={{(fn
39+
(set-flash-message "Clipboard copy failed. The Clipboard API requires a secure context." "danger")
40+
)}}
41+
class="secondary"
42+
/>
43+
</div>
44+
{{/if}}
45+
<div class="control">
46+
<Hds::Button @text="Back" @color="secondary" {{on "click" this.reset}} data-test-back-button />
47+
</div>
48+
</div>
49+
{{else}}
50+
<form {{on "submit" (perform this.sign)}} data-test-secret-generate-form="true">
51+
<div class="box is-sideless is-fullwidth is-marginless">
52+
<MessageError @errorMessage={{this.errorMessage}} />
53+
<NamespaceReminder @mode="sign" @noun="SSH key" />
54+
<FormFieldGroups
55+
@model={{this.signForm}}
56+
@mode="create"
57+
@groupName="formFieldGroups"
58+
@modelValidations={{this.modelValidations}}
59+
/>
60+
{{#if this.invalidFormAlert}}
61+
<AlertInline @type="danger" @message={{this.invalidFormAlert}} class="has-top-padding-s" />
62+
{{/if}}
63+
</div>
64+
<Hds::ButtonSet class="has-top-bottom-margin">
65+
<Hds::Button
66+
@text="Sign"
67+
@icon={{if this.sign.isRunning "loading"}}
68+
type="submit"
69+
disabled={{this.sign.isRunning}}
70+
data-test-submit
71+
/>
72+
<Hds::Button
73+
@text="Cancel"
74+
@color="secondary"
75+
@route="vault.cluster.secrets.backend.list-root"
76+
@model={{@backendPath}}
77+
data-test-cancel
78+
/>
79+
</Hds::ButtonSet>
80+
</form>
81+
{{/if}}

ui/app/components/ssh-sign-key.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
/**
2+
* Copyright IBM Corp. 2016, 2026
3+
* SPDX-License-Identifier: BUSL-1.1
4+
*/
5+
6+
import Component from '@glimmer/component';
7+
import { service } from '@ember/service';
8+
import { action } from '@ember/object';
9+
import { tracked } from '@glimmer/tracking';
10+
import { task } from 'ember-concurrency';
11+
import { waitFor } from '@ember/test-waiters';
12+
import SshSignForm from 'vault/forms/ssh/sign';
13+
14+
import type ApiService from 'vault/services/api';
15+
import type ControlGroupService from 'vault/vault/services/control-group';
16+
17+
interface Args {
18+
roleName: string;
19+
backendPath: string;
20+
}
21+
22+
export default class SshSignKey extends Component<Args> {
23+
@service declare readonly api: ApiService;
24+
@service declare readonly controlGroup: ControlGroupService;
25+
26+
@tracked signForm = new SshSignForm({ cert_type: 'user' });
27+
@tracked signedKeyData: Record<string, unknown> | null = null;
28+
@tracked errorMessage: string | null = null;
29+
@tracked modelValidations: Record<string, unknown> | null = null;
30+
@tracked invalidFormAlert: string | null = null;
31+
32+
get signDisplayRows() {
33+
const data = this.signedKeyData;
34+
if (!data) return [];
35+
return [
36+
{ label: 'Signed key', value: data['signed_key'] },
37+
{ label: 'Lease ID', value: data['lease_id'] },
38+
{ label: 'Renewable', value: data['renewable'] },
39+
{ label: 'Lease duration', value: data['lease_duration'] },
40+
{ label: 'Serial number', value: data['serial_number'] },
41+
].filter((f) => f.value != null && f.value !== '');
42+
}
43+
44+
get breadcrumbs() {
45+
const { backendPath, roleName } = this.args;
46+
return [
47+
{ label: backendPath, route: 'vault.cluster.secrets.backend', model: backendPath },
48+
{ label: 'Sign', route: 'vault.cluster.secrets.backend', model: backendPath },
49+
{ label: roleName, route: 'vault.cluster.secrets.backend.show', model: roleName },
50+
{ label: 'Sign SSH Key' },
51+
];
52+
}
53+
54+
sign = task(
55+
waitFor(async (evt: Event) => {
56+
evt.preventDefault();
57+
this.errorMessage = null;
58+
59+
const { isValid, state, invalidFormMessage, data } = this.signForm.toJSON();
60+
this.modelValidations = isValid ? null : state;
61+
this.invalidFormAlert = isValid ? null : invalidFormMessage;
62+
if (!isValid) return;
63+
try {
64+
const result = await this.api.secrets.sshSignCertificate(
65+
this.args.roleName,
66+
this.args.backendPath,
67+
data
68+
);
69+
this.signedKeyData = { ...result, ...(result.data as Record<string, unknown>) };
70+
} catch (error) {
71+
const { message, response } = await this.api.parseError(error);
72+
if (response?.isControlGroupError) {
73+
this.controlGroup.saveTokenFromError(response);
74+
this.errorMessage = this.controlGroup.logFromError(response).content;
75+
} else {
76+
this.errorMessage = message;
77+
}
78+
}
79+
})
80+
);
81+
82+
@action
83+
reset() {
84+
this.signedKeyData = null;
85+
this.signForm = new SshSignForm({ cert_type: 'user' });
86+
this.errorMessage = null;
87+
this.modelValidations = null;
88+
this.invalidFormAlert = null;
89+
}
90+
}

ui/app/forms/ssh/otp-credential.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
/**
2+
* Copyright IBM Corp. 2016, 2026
3+
* SPDX-License-Identifier: BUSL-1.1
4+
*/
5+
6+
import Form from 'vault/forms/form';
7+
import FormField from 'vault/utils/forms/field';
8+
import { Validations } from 'vault/vault/app-types';
9+
10+
interface SshOtpCredentialData {
11+
username: string;
12+
ip: string;
13+
}
14+
15+
export default class SshOtpCredentialForm extends Form<SshOtpCredentialData> {
16+
formFields = [
17+
new FormField('username', 'string', { label: 'Username' }),
18+
new FormField('ip', 'string', { label: 'IP address' }),
19+
];
20+
21+
validations: Validations = {
22+
ip: [{ type: 'presence', message: 'IP address is required' }],
23+
};
24+
}

0 commit comments

Comments
 (0)