[Feature][UI]: General Settings Follow Up Items (#8965) (#9262)

* UI: VAULT-39172 VAULT-38567 general settings followup (#8910)

* Add unsaved changes fields

* Set up default values for TTL and update general-settings

* Add form error state

* Ass TODO cmment

* Move actions back!

* Update unsaved changes state

* Address comments and add TODOs

* UI: VAULT-39264 Lease Duration TTL picker (#9080)

* Update default and max ttl to show correct default

* Query sys/internal endpoint for ttl values

* WIP ttl-picker-v2

* Intialize values and check for if ttl value is unset

* Use ttlKey instead of name

* Set name to be ttlKey

* Show validation for ttl picker

* Fix validation bugs

* Remove lease duration files

* Add copyright headers

* Initalize only when its a custom value

* Update ttl-picker to not have a dropdown

* Validate field before converting to secs

* [UI] Fix styling and update version card component (#9214)

* Fix styling and update version card component

* Update unsaved changes

* Code cleanup

* More code cleanup!

* Add helper function

* Remove query for lease duration

* Fix outstanding issues

* Captialize unsaved changes

* Update util name

* Remove action helper

* [UI]: General Settings design feedback updates (#9257)

* Small refactor based on design feedback

* More refactoring!

* Rename variables so it makes more sense!

* Remove unused modal fields

Co-authored-by: Kianna <30884335+kiannaquach@users.noreply.github.com>
This commit is contained in:
Vault Automation 2025-09-10 16:27:45 -06:00 committed by GitHub
parent 5ead15b8f2
commit 91eabbd0db
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 460 additions and 177 deletions

View file

@ -2,11 +2,11 @@
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
}}
<Hds::Card::Container @level="mid" @hasBorder={{true}} class="has-padding-s has-top-bottom-margin-12" ...attributes>
<Hds::Card::Container @level="mid" @hasBorder={{true}} class="has-padding-m has-top-bottom-margin" ...attributes>
<Hds::Text::Display @size="300" @tag="h2" class="has-bottom-margin-s hds-foreground-strong">Lease Duration</Hds::Text::Display>
<Hds::Layout::Flex @align="start" @gap="16" class="has-bottom-margin-m">
<Hds::Layout::Flex @direction="column" @gap="4">
<Hds::Text::Display @size="300" @tag="h5">Lease Duration</Hds::Text::Display>
<Hds::Text::Body @size="200" @tag="p">Lease, measured by the “time-to-live” value, defines how long any secret issued
by this engine remains valid.</Hds::Text::Body>
{{! TODO: Verify what link this is supposed to be }}
@ -20,49 +20,7 @@
</Hds::Layout::Flex>
<Hds::Layout::Flex @direction="column" @gap="24" @isInline="true" class="has-top-padding-s has-bottom-padding-s">
<Hds::Form::Select::Field name="TTL-select" @width="150px" {{on "input" this.setTTLType}} as |F|>
<F.Label>Time-to-live (TTL)</F.Label>
<F.HelperText>Standard expiry deadline.</F.HelperText>
<F.Options>
<option value="System default" selected>System default</option>
<option value="Custom">Custom</option>
</F.Options>
</Hds::Form::Select::Field>
{{#if this.enableTTL}}
<Hds::SegmentedGroup as |SG|>
<SG.TextInput @width="100px" size="32" @value={{this.time}} name="time" {{on "input" this.setTtlTime}} />
<SG.Select @width="100px" {{on "input" this.setUnit}} as |S|>
<S.Options>
<option value="s" selected>seconds</option>
<option value="m">minutes</option>
<option value="h">hours</option>
<option value="d">days</option>
</S.Options>
</SG.Select>
</Hds::SegmentedGroup>
{{/if}}
<Hds::Form::Select::Field name="max-TTL-select" @width="150px" {{on "input" this.setMaxTTLType}} as |F|>
<F.Label>Maximum Time-to-live (TTL)</F.Label>
<F.HelperText>Maximum possible extension for expiry.</F.HelperText>
<F.Options>
<option value="System default" selected>System default</option>
<option value="Custom">Custom</option>
</F.Options>
</Hds::Form::Select::Field>
{{#if this.enableMaxTTL}}
<Hds::SegmentedGroup as |SG|>
<SG.TextInput @width="100px" size="32" @value={{this.maxTime}} name="max-time" {{on "input" this.setMaxTtlTime}} />
<SG.Select @width="100px" {{on "input" this.setMaxUnit}} as |S|>
<S.Options>
<option value="s" selected>seconds</option>
<option value="m">minutes</option>
<option value="h">hours</option>
<option value="d">days</option>
</S.Options>
</SG.Select>
</Hds::SegmentedGroup>
{{/if}}
<SecretEngine::TtlPickerV2 @model={{@model}} @ttlKey="default_lease_ttl" />
<SecretEngine::TtlPickerV2 @model={{@model}} @ttlKey="max_lease_ttl" />
</Hds::Layout::Flex>
</Hds::Card::Container>

View file

@ -1,65 +0,0 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import { action } from '@ember/object';
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { HTMLElementEvent } from 'vault/forms';
import SecretsEngineResource from 'vault/resources/secrets/engine';
interface Args {
model: SecretsEngineResource;
}
export default class LeaseDuration extends Component<Args> {
// TODO: When wiring up to parent, address variable names and usage, update onchange functions etc - reference ttl-picker.js
@tracked enableTTL = false;
@tracked enableMaxTTL = false;
@tracked time = '';
@tracked maxTime = '';
@tracked unit = 's';
@tracked maxUnit = 's';
constructor(owner: unknown, args: Args) {
super(owner, args);
}
@action
setTTLType(event: HTMLElementEvent<HTMLInputElement>) {
if (event.target.value === 'Custom') {
this.enableTTL = true;
} else {
this.enableTTL = false;
}
}
@action
setMaxTTLType(event: HTMLElementEvent<HTMLInputElement>) {
if (event.target.value === 'Custom') {
this.enableMaxTTL = true;
} else {
this.enableMaxTTL = false;
}
}
@action
setTtlTime(event: HTMLElementEvent<HTMLInputElement>) {
this.time = event.target.value;
}
@action
setMaxTtlTime(event: HTMLElementEvent<HTMLInputElement>) {
this.maxTime = event.target.value;
}
@action
setUnit(event: HTMLElementEvent<HTMLSelectElement>) {
this.unit = event.target.value;
}
@action
setMaxUnit(event: HTMLElementEvent<HTMLSelectElement>) {
this.maxUnit = event.target.value;
}
}

View file

@ -2,8 +2,8 @@
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
}}
<Hds::Card::Container @level="mid" @hasBorder={{true}} class="has-padding-s has-top-bottom-margin-12" ...attributes>
<Hds::Text::Display @size="300">Metadata</Hds::Text::Display>
<Hds::Card::Container @level="mid" @hasBorder={{true}} class="has-padding-m has-top-bottom-margin-12" ...attributes>
<Hds::Text::Display @size="300" @tag="h2" class="has-bottom-margin-s hds-foreground-strong">Metadata</Hds::Text::Display>
<div class="flex gap-16 is-flex-column has-top-padding-s">
<Hds::Form::Fieldset @layout="vertical" as |F|>

View file

@ -2,17 +2,21 @@
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
}}
<Hds::Card::Container @level="mid" @hasBorder={{true}} class="has-padding-s has-top-bottom-margin-12" ...attributes>
<Hds::Card::Container @level="mid" @hasBorder={{true}} class="has-padding-m has-top-bottom-margin" ...attributes>
<Hds::Form::Toggle::Group as |G|>
<G.Legend>
<Hds::Text::Display @size="300">Security</Hds::Text::Display>
<Hds::Text::Display
@size="300"
@tag="h2"
class="has-bottom-margin-s hds-foreground-strong"
>Security</Hds::Text::Display>
</G.Legend>
{{! TODO: Toggle fields here are currently hardcoded, will be replaced with actual data from model once wired into parent component }}
<G.ToggleField name="local" checked={{@model.secretsEngine.local}} as |F|>
{{! TODO: Confirm with design to see if we want these two fields to be disabled }}
<G.ToggleField name="local" checked={{@model.secretsEngine.local}} disabled as |F|>
<F.Label>Local</F.Label>
<F.HelperText>Secrets stay in one cluster and are not replicated.</F.HelperText>
</G.ToggleField>
<G.ToggleField name="seal-wrap" checked={{@model.secretsEngine.seal_wrap}} as |F|>
<G.ToggleField name="seal-wrap" checked={{@model.secretsEngine.seal_wrap}} disabled as |F|>
<F.Label>Seal wrap</F.Label>
<F.HelperText>Wrap secrets with an additional encryption layer using a seal.</F.HelperText>
</G.ToggleField>

View file

@ -2,40 +2,38 @@
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
}}
<Hds::Card::Container @level="mid" @hasBorder={{true}} class="has-padding-s has-top-bottom-margin-12" ...attributes>
<Hds::Text::Display @size="300">Version</Hds::Text::Display>
<Hds::Card::Container @level="mid" @hasBorder={{true}} class="has-padding-m has-top-bottom-margin" ...attributes>
<Hds::Text::Display @size="300" @tag="h2" class="has-bottom-margin-s hds-foreground-strong">Version</Hds::Text::Display>
{{! TODO: Having this set up with flex for now, grid might be better? }}
<div class="flex gap-36 has-top-margin-l">
<Hds::Layout::Flex @direction="column" @gap="8">
<Hds::Text::Display>Engine type</Hds::Text::Display>
<Hds::Text::Display>Current version</Hds::Text::Display>
<Hds::Text::Display>Latest version</Hds::Text::Display>
</Hds::Layout::Flex>
<Hds::Layout::Grid @columnMinWidth="10%" @gap="12" {{style height="100%" grid-template-rows="min-content"}} as |LG|>
<LG.Item @colspan={{2}}>
<Hds::Text::Body @tag="h3" class="hds-font-weight-semibold has-bottom-margin-s hds-foreground-strong">Engine type</Hds::Text::Body>
<Hds::Text::Body @tag="h3" class="hds-font-weight-semibold has-bottom-margin-s hds-foreground-strong">Current version</Hds::Text::Body>
</LG.Item>
<Hds::Layout::Flex @direction="column" @gap="12" @align="start">
<LG.Item @colspan={{3}}>
<Hds::Text::Body
@tag="p"
class="hds-border-strong side-padding-4 border-radius-4"
class="hds-border-strong has-side-padding-8 border-radius-4 is-inline-block has-bottom-margin-s"
>{{@model.secretsEngine.type}}</Hds::Text::Body>
{{! TODO: Verify if we want to display the full version or chop down ie. v0.17.1 vs v0.17.1-0.230942309423094... }}
<Hds::Text::Body @tag="p">{{@model.secretsEngine.running_plugin_version}}</Hds::Text::Body>
{{! TODO: leaving as is for now to match design, but we might be removing this if we cant get latest version from some source }}
<Hds::Text::Body @tag="p">v.12.46</Hds::Text::Body>
</LG.Item>
</Hds::Layout::Grid>
{{#if (gt @model.versions.length 1)}}
<Hds::Separator />
<Hds::Layout::Flex @isInline="true">
<Hds::Form::Select::Field name="plugin-version" as |F|>
<F.Label>Update version to:</F.Label>
<F.Options>
<option value="">Select version</option>
{{#each @model.versions as |version|}}
<option value={{version}}>{{version}}</option>
{{/each}}
</F.Options>
</Hds::Form::Select::Field>
</Hds::Layout::Flex>
</div>
<Hds::Separator />
<Hds::Layout::Flex @isInline="true">
<Hds::Form::Select::Field name="version-select" as |F|>
<F.Label>Update version to:</F.Label>
{{! TODO: Update options with available versions from API }}
<F.Options>
<option value="">Select version</option>
<option value="12.4">12.4</option>
<option value="12.43">12.43</option>
</F.Options>
</Hds::Form::Select::Field>
</Hds::Layout::Flex>
{{/if}}
</Hds::Card::Container>

View file

@ -9,13 +9,13 @@
<PH.Description>{{get engineData "displayName"}}</PH.Description>
<PH.Breadcrumb>
<Hds::Breadcrumb>
<Hds::Breadcrumb::Item @text="Secrets" />
<Hds::Breadcrumb::Item @text="Secrets" @route="vault.cluster.secrets" />
<Hds::Breadcrumb::Item
@text={{@model.secretsEngine.id}}
@route="vault.cluster.secrets.backend.list-root"
@model={{@model.secretsEngine.id}}
/>
<Hds::Breadcrumb::Item @text="Configuration" />
<Hds::Breadcrumb::Item @text="Configuration" @current={{true}} />
</Hds::Breadcrumb>
</PH.Breadcrumb>
<PH.IconTile @icon={{get engineData "glyph"}} />

View file

@ -14,39 +14,39 @@
<Hds::Text::Display @tag="h2">Saving configuration...</Hds::Text::Display>
</Hds::Layout::Flex>
{{else}}
<form method="POST" {{on "submit" (perform this.saveGeneralSettings)}} aria-label="general settings form">
<form
method="POST"
{{on "submit" (perform this.saveGeneralSettings)}}
aria-label="general settings form"
id="general-settings-form"
>
<Hds::Text::Body class="has-top-bottom-margin-xxs">
Mount parameters that you can tune to fit required engine behavior.
</Hds::Text::Body>
<Hds::Layout::Flex @direction="row" @gap="32" @align="start">
<Hds::Layout::Flex @direction="column" @gap="8">
<MessageError @errorMessage={{this.errorMessage}} />
<Hds::Layout::Grid @columnMinWidth="32%" @gap="32" as |LG|>
<LG.Item @colspan={{1}}>
<SecretEngine::Card::Version @model={{@model}} class="is-fullwidth" />
<SecretEngine::Card::Metadata @model={{@model}} class="is-fullwidth" />
</Hds::Layout::Flex>
<Hds::Layout::Flex @direction="column" @gap="8">
</LG.Item>
<LG.Item @colspan={{1}}>
<SecretEngine::Card::LeaseDuration @model={{@model}} class="is-fullwidth" />
<SecretEngine::Card::Security @model={{@model}} class="is-fullwidth" />
</Hds::Layout::Flex>
</Hds::Layout::Flex>
<div class="field is-grouped has-top-bottom-margin-12">
</LG.Item>
</Hds::Layout::Grid>
<div class="field is-grouped has-top-bottom-margin">
<Hds::ButtonSet>
<Hds::Button @text="Save changes" type="submit" disabled={{this.saveGeneralSettings.isRunning}} data-test-submit />
<Hds::Button
@text="Discard"
@color="secondary"
data-test-cancel
{{on "click" (fn (mut this.showUnsavedChangesModal))}}
/>
<Hds::Button @text="Discard" @color="secondary" data-test-cancel {{on "click" this.openUnsavedChangesModal}} />
</Hds::ButtonSet>
</div>
</form>
{{/if}}
{{#if this.showUnsavedChangesModal}}
<Hds::Modal id="unsavedChangesModal" @onClose={{(fn (mut this.showUnsavedChangesModal))}} as |M|>
<Hds::Modal id="unsavedChangesModal" @onClose={{this.closeUnsavedChangesModal}} as |M|>
<M.Header>
Unsaved changes
</M.Header>
@ -54,14 +54,19 @@
<p class="hds-typography-body-300 hds-foreground-primary">You've made changes to the following
<Hds::Text::Display>{{@model.secretsEngine.id}}</Hds::Text::Display>
settings:</p>
{{! TODO: display what fields were changed }}
<br />
{{#each this.changedFields as |changedField|}}
<Hds::Text::Display>{{capitalize changedField}}</Hds::Text::Display>
<br />
{{/each}}
<br />
Would you like to apply them?
</M.Body>
<M.Footer as |F|>
<Hds::Button type="button" @text="Save changes" {{on "click" (action (perform this.saveGeneralSettings))}} />
{{! TODO: confirm with design where we want to transition to if Discard changes is clicked }}
<Hds::Button type="button" @text="Discard changes" @color="secondary" {{on "click" F.close}} />
<M.Footer>
<Hds::ButtonSet>
<Hds::Button type="button" @text="Save changes" {{on "click" (perform this.saveGeneralSettings)}} />
<Hds::Button type="button" @text="Discard changes" @color="secondary" {{on "click" this.discardChanges}} />
</Hds::ButtonSet>
</M.Footer>
</Hds::Modal>
{{/if}}

View file

@ -7,12 +7,17 @@ import Component from '@glimmer/component';
import { task } from 'ember-concurrency';
import { service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { convertToSeconds } from 'core/utils/duration-utils';
import type Router from '@ember/routing/router';
import type FlashMessageService from 'vault/services/flash-messages';
import type ApiService from 'vault/services/api';
import type SecretsEngineResource from 'vault/resources/secrets/engine';
export const CUSTOM = 'Custom';
export const SYSTEM_DEFAULT = 'System default';
/**
* @module GeneralSettingsComponent is used to configure the SSH secret engine.
*
@ -24,11 +29,13 @@ import type SecretsEngineResource from 'vault/resources/secrets/engine';
* ```
*
* @param {string} secretsEngine - secrets engine resource
* @param {string} versions - list of versions for a given secret engine
*/
interface Args {
model: {
secretsEngine: SecretsEngineResource;
versions: string[];
};
}
@ -40,20 +47,177 @@ export default class GeneralSettingsComponent extends Component<Args> {
@tracked errorMessage: string | null = null;
@tracked invalidFormAlert: string | null = null;
@tracked showUnsavedChangesModal = false;
@tracked changedFields: string[] = [];
originalModel = JSON.parse(JSON.stringify(this.args.model));
getUnsavedChanges(newModel: SecretsEngineResource, originalModel: SecretsEngineResource) {
for (const key in this.args.model.secretsEngine) {
const secretsEngineKeyType = key as keyof typeof this.args.model.secretsEngine;
if (secretsEngineKeyType === 'options') {
return;
}
if (secretsEngineKeyType === 'config') {
const { defaultLeaseTime, defaultLeaseUnit, maxLeaseTime, maxLeaseUnit } = this.getFormData();
const hasDefaultTtlValueChanged = this.hasTtlValueChanged(
defaultLeaseTime,
defaultLeaseUnit,
'default_lease_ttl'
);
const hasMaxTtlValueChanged = this.hasTtlValueChanged(maxLeaseTime, maxLeaseUnit, 'max_lease_ttl');
if (
(hasDefaultTtlValueChanged || hasMaxTtlValueChanged) &&
!this.changedFields.includes('Lease Duration')
) {
this.changedFields.push('Lease Duration');
}
} else {
if (newModel[secretsEngineKeyType] !== originalModel[secretsEngineKeyType]) {
this.changedFields.push(key);
}
}
}
}
validateTtl(ttlValue: FormDataEntryValue | number | null) {
if (isNaN(Number(ttlValue))) {
this.errorMessage = 'Only use numbers for this setting.';
return false;
}
return true;
}
hasTtlValueChanged(ttlTime: number, ttlUnit: string, ttlKey: 'max_lease_ttl' | 'default_lease_ttl') {
const defaultLeaseInSecs = convertToSeconds(ttlTime, ttlUnit);
if (defaultLeaseInSecs === this?.originalModel?.secretsEngine?.config[ttlKey]) {
return false;
}
return true;
}
hasDescriptionChanged(description: FormDataEntryValue | null) {
return description !== this?.originalModel?.secretsEngine?.description;
}
hasPluginVersionChanged(version: FormDataEntryValue | null) {
return version && version !== this?.originalModel?.secretsEngine?.running_plugin_version;
}
getFormData() {
const form = document.getElementById('general-settings-form');
const fd = new FormData(form as HTMLFormElement);
const fdDefaultLeaseTime = Number(fd.get('default_lease_ttl-time'));
const fdDefaultLeaseUnit = fd.get('default_lease_ttl-unit')?.toString() || 's';
const fdMaxLeaseTime = Number(fd.get('max_lease_ttl-time'));
const fdMaxLeaseUnit = fd.get('max_lease_ttl-unit')?.toString() || 's';
return {
defaultLeaseTime: fdDefaultLeaseTime,
defaultLeaseUnit: fdDefaultLeaseUnit,
maxLeaseTime: fdMaxLeaseTime,
maxLeaseUnit: fdMaxLeaseUnit,
description: fd.get('description'),
version: fd.get('plugin-version'),
};
}
hasUnsavedChanges() {
const { defaultLeaseTime, defaultLeaseUnit, maxLeaseTime, maxLeaseUnit, description, version } =
this.getFormData();
const hasDefaultTtlValueChanged = this.hasTtlValueChanged(
defaultLeaseTime,
defaultLeaseUnit,
'default_lease_ttl'
);
const hasMaxTtlValueChanged = this.hasTtlValueChanged(maxLeaseTime, maxLeaseUnit, 'max_lease_ttl');
return (
hasDefaultTtlValueChanged ||
hasMaxTtlValueChanged ||
this.hasDescriptionChanged(description) ||
this.hasPluginVersionChanged(version)
);
}
formatTuneParams() {
const { defaultLeaseTime, defaultLeaseUnit, maxLeaseTime, maxLeaseUnit, description, version } =
this.getFormData();
const hasDefaultTtlValueChanged = this.hasTtlValueChanged(
defaultLeaseTime,
defaultLeaseUnit,
'default_lease_ttl'
);
const hasMaxTtlValueChanged = this.hasTtlValueChanged(maxLeaseTime, maxLeaseUnit, 'max_lease_ttl');
const defaultLeaseTtl = hasDefaultTtlValueChanged ? `${defaultLeaseTime}${defaultLeaseUnit}` : undefined;
const maxLeaseTtl = hasMaxTtlValueChanged ? `${maxLeaseTime}${maxLeaseUnit}` : undefined;
const pluginVersion = this.hasPluginVersionChanged(version) ? version : undefined;
const pluginDescription = this.hasDescriptionChanged(description) ? description : undefined;
return {
defaultLeaseTtl,
maxLeaseTtl,
pluginVersion,
pluginDescription,
};
}
@action
openUnsavedChangesModal() {
if (this.hasUnsavedChanges()) {
this.getUnsavedChanges(this.args?.model?.secretsEngine, this?.originalModel?.secretsEngine);
this.showUnsavedChangesModal = true;
} else {
this.showUnsavedChangesModal = false;
}
}
@action
closeUnsavedChangesModal() {
this.showUnsavedChangesModal = !this.showUnsavedChangesModal;
this.changedFields = [];
}
@action
discardChanges() {
this.closeUnsavedChangesModal();
this.router.transitionTo(this.args?.model?.secretsEngine?.backendConfigurationLink);
}
saveGeneralSettings = task(async (event) => {
event.preventDefault();
const { defaultLeaseTime, maxLeaseTime } = this.getFormData();
if (!this.validateTtl(defaultLeaseTime) || !this.validateTtl(maxLeaseTime)) {
this.errorMessage = 'Only use numbers for this setting.';
return;
}
try {
const fd = new FormData(event.target as HTMLFormElement);
await this.api.sys.mountsTuneConfigurationParameters(this.args.model.secretsEngine.id, {
// TODO: add other params when other card components are made
description: fd.get('description') as string,
const { defaultLeaseTtl, maxLeaseTtl, pluginVersion, pluginDescription } = this.formatTuneParams();
await this.api.sys.mountsTuneConfigurationParameters(this.args?.model?.secretsEngine?.id, {
description: pluginDescription as string | undefined,
default_lease_ttl: defaultLeaseTtl,
max_lease_ttl: maxLeaseTtl,
plugin_version: pluginVersion as string | undefined,
});
this.flashMessages.success('Engine settings successfully updated.');
this.router.transitionTo(this.args?.model?.secretsEngine?.backendConfigurationLink);
} catch (e) {
// handle error state
const { message } = await this.api.parseError(e);
this.errorMessage = message;
this.flashMessages.danger(`Try again or check your network connection. ${message}`);
}
});

View file

@ -0,0 +1,31 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
}}
<Hds::Form::Field @layout="vertical" @isInvalid={{this.errorMessage}} as |F|>
<F.Label>{{this.formField.label}}</F.Label>
<F.HelperText>{{this.formField.helperText}}</F.HelperText>
<F.Control>
<Hds::SegmentedGroup as |SG|>
<SG.TextInput
@width="100px"
size="32"
@value={{this.time}}
{{on "input" this.setTtlTime}}
name="{{@ttlKey}}-time"
autocomplete="off"
/>
<SG.Select @width="100px" name="{{@ttlKey}}-unit" {{on "input" this.setUnit}} as |S|>
<S.Options>
{{#each this.unitOptions as |unit|}}
<option value={{unit.value}} selected={{eq unit.value this.selectedUnit}}>{{unit.label}}</option>
{{/each}}
</S.Options>
</SG.Select>
</Hds::SegmentedGroup>
</F.Control>
{{#if this.errorMessage}}
<F.Error>{{this.errorMessage}}</F.Error>
{{/if}}
</Hds::Form::Field>

View file

@ -0,0 +1,113 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
import { convertFromSeconds, durationToSeconds, largestUnitFromSeconds } from 'core/utils/duration-utils';
import { CUSTOM, SYSTEM_DEFAULT } from './page/general-settings';
import type FlashMessageService from 'vault/services/flash-messages';
import type SecretsEngineResource from 'vault/resources/secrets/engine';
import type ApiService from 'vault/services/api';
import type RouterService from '@ember/routing/router-service';
import type { HTMLElementEvent } from 'vault/forms';
/**
* @module TtlPickerV2 handles the display of the ttl picker fo the lease duration card in general settings.
*
* @example
* <SecretEngine::TtlPickerV2
@model={{this.model}}
@isDefaultTtlPicker={{boolean}}
/>
*
* @param {object} model - A model contains a secret engine resource, lease config from the sys/internal endpoint.
* @param {boolean} isDefaultTtlPicker - isDefaultTtlPicker is a boolean that determines if the picker is default or max ttl.
*/
interface Args {
model: {
secretsEngine: SecretsEngineResource;
};
ttlKey: 'default_lease_ttl' | 'max_lease_ttl';
}
export default class TtlPickerV2 extends Component<Args> {
systemDefaultTtl = 0;
systemDefault = SYSTEM_DEFAULT;
custom = CUSTOM;
@service declare readonly flashMessages: FlashMessageService;
@service declare readonly api: ApiService;
@service declare readonly router: RouterService;
@tracked selectedUnit = 's';
@tracked time = '';
@tracked errorMessage = '';
constructor(owner: unknown, args: Args) {
super(owner, args);
this.initializeTtl();
}
initializeTtl() {
const ttlValue = this.args?.model?.secretsEngine?.config[this.args.ttlKey];
let seconds = 0;
if (typeof ttlValue === 'number') {
// if the passed value is a number, assume unit is seconds
seconds = ttlValue;
} else {
const parseDuration = durationToSeconds(ttlValue || '');
// if parsing fails leave it empty
if (parseDuration === null) return;
seconds = parseDuration;
}
const unit = largestUnitFromSeconds(seconds);
const time = convertFromSeconds(seconds, unit);
this.time = time.toString() || '';
this.selectedUnit = unit;
}
get unitOptions() {
return [
{ label: 'seconds', value: 's' },
{ label: 'minutes', value: 'm' },
{ label: 'hours', value: 'h' },
{ label: 'days', value: 'd' },
];
}
get formField() {
return {
label: this.args?.ttlKey === 'default_lease_ttl' ? 'Time-to-live (TTL)' : 'Maximum Time-to-live (TTL)',
helperText:
this.args?.ttlKey === 'default_lease_ttl'
? 'Standard expiry deadline.'
: 'Maximum possible extension for expiry.',
};
}
@action
setTtlTime(event: HTMLElementEvent<HTMLInputElement>) {
this.errorMessage = '';
if (isNaN(Number(event.target.value))) {
this.errorMessage = 'Only use numbers for this setting.';
return;
}
this.time = event.target.value;
this.args.model.secretsEngine.config[this.args.ttlKey] = event.target.value;
}
@action
setUnit(event: HTMLElementEvent<HTMLSelectElement>) {
this.selectedUnit = event.target.value;
}
}

View file

@ -4,13 +4,23 @@
*/
import Route from '@ember/routing/route';
import { service } from '@ember/service';
import { getPluginVersionsFromEngineType } from 'vault/utils/plugin-catalog-helpers';
import type SecretsEngineResource from 'vault/resources/secrets/engine';
import type ApiService from 'vault/services/api';
import type PluginCatalogService from 'vault/services/plugin-catalog';
export default class SecretsBackendConfigurationGeneralSettingsRoute extends Route {
@service declare readonly api: ApiService;
@service('plugin-catalog') declare readonly pluginCatalog: PluginCatalogService;
async model() {
const secretsEngine = this.modelFor('vault.cluster.secrets.backend') as SecretsEngineResource;
// TODO: get list of versions using the sys/plugins/catalog endpoint.
return { secretsEngine };
const { data } = await this.pluginCatalog.getRawPluginCatalogData();
const versions = getPluginVersionsFromEngineType(data?.secret, secretsEngine.type);
return { secretsEngine, versions };
}
}

View file

@ -36,6 +36,16 @@ export interface PluginCatalogResponse {
error: boolean;
}
export interface PluginCatalogRawDataResponse {
data: {
secret: Array<PluginCatalogPlugin>;
auth: Array<PluginCatalogPlugin>;
database: Array<PluginCatalogPlugin>;
detailed: Array<PluginCatalogPlugin>;
} | null;
error: boolean;
}
export default class PluginCatalogService extends Service {
@service declare readonly api: ApiService;
@service declare readonly auth: AuthService;
@ -93,6 +103,52 @@ export default class PluginCatalogService extends Service {
}
}
/**
* Fetches the plugin catalog from the Vault API and groups the data by plugin type
* Uses the API service middleware for authentication, namespacing, and error handling
* @returns Promise resolving to plugin catalog data and error state
*/
async getRawPluginCatalogData(): Promise<PluginCatalogRawDataResponse> {
try {
const response = await this.api.sys.pluginsCatalogListPlugins({
headers: {
token: this.auth.currentToken,
namespace: sanitizePath(this.namespace.path),
},
});
if (response && response.detailed && Array.isArray(response.detailed) && response.detailed.length > 0) {
const detailedPlugins = response.detailed as PluginCatalogPlugin[];
const secretPlugins = detailedPlugins.filter((plugin) => plugin.type === 'secret');
const authPlugins = detailedPlugins.filter((plugin) => plugin.type === 'auth');
const databasePlugins = detailedPlugins.filter((plugin) => plugin.type === 'database');
return {
data: {
secret: secretPlugins,
auth: authPlugins,
database: databasePlugins,
detailed: detailedPlugins,
},
error: false,
};
}
return {
data: null,
error: true,
};
} catch (error) {
return {
data: null,
error: true,
};
}
}
/**
* Gets plugins of a specific type from the catalog
* @param type - The plugin type ('secret', 'auth', 'database')

View file

@ -203,3 +203,12 @@ export function categorizeEnginesByStatus(engines: EnhancedEngineDisplayData[]):
return { enabled, disabled };
}
export function getPluginVersionsFromEngineType(list: PluginCatalogPlugin[] | undefined, name: string) {
if (!list) return [];
return list.reduce((acc: string[], item: PluginCatalogPlugin) => {
if (item.name === name) acc.push(item.version);
return acc;
}, []);
}