{{/if}}
@@ -334,7 +349,7 @@
@type="warning"
@message={{this.validationWarning}}
@paddingTop={{if (and (not this.validationError) (eq @attr.options.editType "ttl")) false true}}
- data-test-validation-warning={{@attr.name}}
+ data-test-validation-warning={{this.valuePath}}
class={{if (and (not this.validationError) (eq @attr.options.editType "stringArray")) "has-top-margin-negative-xxl"}}
/>
{{/if}}
diff --git a/ui/lib/core/addon/components/form-field.js b/ui/lib/core/addon/components/form-field.js
index 5552b8bc6f..f1aa94376c 100644
--- a/ui/lib/core/addon/components/form-field.js
+++ b/ui/lib/core/addon/components/form-field.js
@@ -184,4 +184,12 @@ export default class FormFieldComponent extends Component {
const prop = event.target.type === 'checkbox' ? 'checked' : 'value';
this.setAndBroadcast(event.target[prop]);
}
+
+ @action
+ handleChecklist(event) {
+ const valueArray = this.args.model[this.valuePath];
+ const method = event.target.checked ? 'addObject' : 'removeObject';
+ valueArray[method](event.target.value);
+ this.setAndBroadcast(valueArray);
+ }
}
diff --git a/ui/lib/core/addon/components/kv-suggestion-input.hbs b/ui/lib/core/addon/components/kv-suggestion-input.hbs
new file mode 100644
index 0000000000..f277a5afd7
--- /dev/null
+++ b/ui/lib/core/addon/components/kv-suggestion-input.hbs
@@ -0,0 +1,35 @@
+{{!
+ Copyright (c) HashiCorp, Inc.
+ SPDX-License-Identifier: BUSL-1.1
+~}}
+
+ {{#if @label}}
+
+ {{/if}}
+
+
+ {{secret.path}}
+
+
\ No newline at end of file
diff --git a/ui/lib/core/addon/components/kv-suggestion-input.ts b/ui/lib/core/addon/components/kv-suggestion-input.ts
new file mode 100644
index 0000000000..55bb7fdcbc
--- /dev/null
+++ b/ui/lib/core/addon/components/kv-suggestion-input.ts
@@ -0,0 +1,145 @@
+/**
+ * Copyright (c) HashiCorp, Inc.
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import Component from '@glimmer/component';
+import { service } from '@ember/service';
+import { tracked } from '@glimmer/tracking';
+import { action } from '@ember/object';
+import { guidFor } from '@ember/object/internals';
+import { run } from '@ember/runloop';
+import { keyIsFolder, parentKeyForKey, keyWithoutParentKey } from 'core/utils/key-utils';
+
+import type StoreService from 'vault/services/store';
+import type KvSecretMetadataModel from 'vault/models/kv/metadata';
+
+/**
+ * @module KvSuggestionInput
+ * Input component that fetches secrets at a provided mount path and displays them as suggestions in a dropdown
+ * As the user types the result set will be filtered providing suggestions for the user to select
+ * After the input debounce wait time (500ms), if the value ends in a slash, secrets will be fetched at that path
+ * The new result set will then be displayed in the dropdown as suggestions for the newly inputted path
+ * Selecting a suggestion will append it to the input value
+ * This allows the user to build a full path to a secret for the provided mount
+ * This is useful for helping the user find deeply nested secrets given the path based policy system
+ * If the user does not have list permission they are still able to enter a path to a secret but will not see suggestions
+ *
+ * @example
+ *
+ */
+
+interface Args {
+ label: string;
+ subText?: string;
+ mountPath: string;
+ value: string;
+ onChange: CallableFunction;
+}
+
+interface PowerSelectAPI {
+ actions: {
+ open(): void;
+ close(): void;
+ };
+}
+
+export default class KvSuggestionInputComponent extends Component
{
+ @service declare readonly store: StoreService;
+
+ @tracked secrets: KvSecretMetadataModel[] = [];
+ powerSelectAPI: PowerSelectAPI | undefined;
+ _cachedSecrets: KvSecretMetadataModel[] = []; // cache the response for filtering purposes
+ inputId = `suggestion-input-${guidFor(this)}`; // add unique segment to id in case multiple instances of component are used on the same page
+
+ constructor(owner: unknown, args: Args) {
+ super(owner, args);
+ if (this.args.mountPath) {
+ this.updateSuggestions();
+ }
+ }
+
+ async fetchSecrets(isDirectory: boolean) {
+ const { mountPath } = this.args;
+ try {
+ const backend = keyIsFolder(mountPath) ? mountPath.slice(0, -1) : mountPath;
+ const parentDirectory = parentKeyForKey(this.args.value);
+ const pathToSecret = isDirectory ? this.args.value : parentDirectory;
+ const kvModels = (await this.store.query('kv/metadata', {
+ backend,
+ pathToSecret,
+ })) as unknown;
+ // this will be used to filter the existing result set when the search term changes within the same path
+ this._cachedSecrets = kvModels as KvSecretMetadataModel[];
+ return this._cachedSecrets;
+ } catch (error) {
+ console.log(error); // eslint-disable-line
+ return [];
+ }
+ }
+
+ filterSecrets(kvModels: KvSecretMetadataModel[] | undefined = [], isDirectory: boolean) {
+ const { value } = this.args;
+ const secretName = keyWithoutParentKey(value) || '';
+ return kvModels.filter((model) => {
+ if (!value || isDirectory) {
+ return true;
+ }
+ if (value === model.fullSecretPath) {
+ // don't show suggestion if it's currently selected
+ return false;
+ }
+ return model.path.toLowerCase().includes(secretName.toLowerCase());
+ });
+ }
+
+ @action
+ async updateSuggestions() {
+ const isFirstUpdate = !this._cachedSecrets.length;
+ const isDirectory = keyIsFolder(this.args.value);
+ if (!this.args.mountPath) {
+ this.secrets = [];
+ } else if (this.args.value && !isDirectory && this.secrets) {
+ // if we don't need to fetch from a new path, filter the previous result set with the updated search term
+ this.secrets = this.filterSecrets(this._cachedSecrets, isDirectory);
+ } else {
+ const kvModels = await this.fetchSecrets(isDirectory);
+ this.secrets = this.filterSecrets(kvModels, isDirectory);
+ }
+ // don't do anything on first update -- allow dropdown to open on input click
+ if (!isFirstUpdate) {
+ const action = this.secrets.length ? 'open' : 'close';
+ this.powerSelectAPI?.actions[action]();
+ }
+ }
+
+ @action
+ onInput(value: string) {
+ this.args.onChange(value);
+ this.updateSuggestions();
+ }
+
+ @action
+ onInputClick() {
+ if (this.secrets.length) {
+ this.powerSelectAPI?.actions.open();
+ }
+ }
+
+ @action
+ onSuggestionSelect(secret: KvSecretMetadataModel) {
+ // user may partially type a value to filter result set and then select a suggestion
+ // in this case the partially typed value must be replaced with suggestion value
+ // the fullSecretPath contains the previous selections or typed path segments
+ this.args.onChange(secret.fullSecretPath);
+ this.updateSuggestions();
+ // refocus the input after selection
+ run(() => document.getElementById(this.inputId)?.focus());
+ }
+}
diff --git a/ui/lib/core/addon/components/overview-card.hbs b/ui/lib/core/addon/components/overview-card.hbs
index d57304a5a8..29bcee56d9 100644
--- a/ui/lib/core/addon/components/overview-card.hbs
+++ b/ui/lib/core/addon/components/overview-card.hbs
@@ -18,6 +18,7 @@
@iconPosition="trailing"
@text={{@actionText}}
@route={{@actionTo}}
+ @isRouteExternal={{@actionExternal}}
@query={{@actionQuery}}
data-test-action-text={{@actionText}}
/>
diff --git a/ui/lib/core/addon/components/sync-status-badge.hbs b/ui/lib/core/addon/components/sync-status-badge.hbs
new file mode 100644
index 0000000000..e3111f9d18
--- /dev/null
+++ b/ui/lib/core/addon/components/sync-status-badge.hbs
@@ -0,0 +1,6 @@
+{{!
+ Copyright (c) HashiCorp, Inc.
+ SPDX-License-Identifier: BUSL-1.1
+~}}
+
+
\ No newline at end of file
diff --git a/ui/lib/core/addon/components/sync-status-badge.ts b/ui/lib/core/addon/components/sync-status-badge.ts
new file mode 100644
index 0000000000..754ad9242d
--- /dev/null
+++ b/ui/lib/core/addon/components/sync-status-badge.ts
@@ -0,0 +1,62 @@
+/**
+ * Copyright (c) HashiCorp, Inc.
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import Component from '@glimmer/component';
+
+interface Args {
+ status: string; // https://developer.hashicorp.com/vault/docs/sync#sync-statuses
+}
+
+export default class SyncStatusBadge extends Component {
+ get state() {
+ switch (this.args.status) {
+ case 'SYNCING':
+ return {
+ icon: 'sync',
+ color: 'neutral',
+ };
+ case 'SYNCED':
+ return {
+ icon: 'check-circle',
+ color: 'success',
+ };
+ case 'UNSYNCING':
+ return {
+ icon: 'sync-reverse',
+ color: 'neutral',
+ };
+ case 'UNSYNCED':
+ return {
+ icon: 'sync-alert',
+ color: 'warning',
+ };
+ case 'INTERNAL_VAULT_ERROR':
+ return {
+ icon: 'x-circle',
+ color: 'critical',
+ };
+ case 'CLIENT_SIDE_ERROR':
+ return {
+ icon: 'x-circle',
+ color: 'critical',
+ };
+ case 'EXTERNAL_SERVICE_ERROR':
+ return {
+ icon: 'x-circle',
+ color: 'critical',
+ };
+ case 'UNKNOWN':
+ return {
+ icon: 'help',
+ color: 'neutral',
+ };
+ default:
+ return {
+ icon: 'help',
+ color: 'neutral',
+ };
+ }
+ }
+}
diff --git a/ui/lib/core/addon/helpers/sync-destinations.ts b/ui/lib/core/addon/helpers/sync-destinations.ts
new file mode 100644
index 0000000000..1ce6c23659
--- /dev/null
+++ b/ui/lib/core/addon/helpers/sync-destinations.ts
@@ -0,0 +1,66 @@
+/**
+ * Copyright (c) HashiCorp, Inc.
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import { helper as buildHelper } from '@ember/component/helper';
+
+import type { SyncDestination, SyncDestinationType } from 'vault/vault/helpers/sync-destinations';
+
+/*
+This helper is referenced in the base sync destination model
+to return static display attributes that rely on type
+maskedParams: attributes for sensitive data, the API returns these values as '*****'
+*/
+
+const SYNC_DESTINATIONS: Array = [
+ {
+ name: 'AWS Secrets Manager',
+ type: 'aws-sm',
+ icon: 'aws-color',
+ category: 'cloud',
+ maskedParams: ['accessKeyId', 'secretAccessKey'],
+ },
+ {
+ name: 'Azure Key Vault',
+ type: 'azure-kv',
+ icon: 'azure-color',
+ category: 'cloud',
+ maskedParams: ['clientSecret'],
+ },
+ {
+ name: 'Google Secret Manager',
+ type: 'gcp-sm',
+ icon: 'gcp-color',
+ category: 'cloud',
+ maskedParams: ['credentials'],
+ },
+ {
+ name: 'Github Actions',
+ type: 'gh',
+ icon: 'github-color',
+ category: 'dev-tools',
+ maskedParams: ['accessToken'],
+ },
+ {
+ name: 'Vercel Project',
+ type: 'vercel-project',
+ icon: 'vercel-color',
+ category: 'dev-tools',
+ maskedParams: ['accessToken'],
+ },
+];
+
+export function syncDestinations(): Array {
+ return [...SYNC_DESTINATIONS];
+}
+
+export function destinationTypes(): Array {
+ return SYNC_DESTINATIONS.map((d) => d.type);
+}
+
+export function findDestination(type: SyncDestinationType | undefined): SyncDestination | undefined {
+ return SYNC_DESTINATIONS.find((d) => d.type === type);
+}
+
+export default buildHelper(syncDestinations);
diff --git a/ui/lib/core/app/components/kv-suggestion-input.js b/ui/lib/core/app/components/kv-suggestion-input.js
new file mode 100644
index 0000000000..9d577d015b
--- /dev/null
+++ b/ui/lib/core/app/components/kv-suggestion-input.js
@@ -0,0 +1,6 @@
+/**
+ * Copyright (c) HashiCorp, Inc.
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+export { default } from 'core/components/kv-suggestion-input';
diff --git a/ui/lib/core/app/components/sync-status-badge.js b/ui/lib/core/app/components/sync-status-badge.js
new file mode 100644
index 0000000000..4e857d6656
--- /dev/null
+++ b/ui/lib/core/app/components/sync-status-badge.js
@@ -0,0 +1,6 @@
+/**
+ * Copyright (c) HashiCorp, Inc.
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+export { default } from 'core/components/sync-status-badge';
diff --git a/ui/lib/core/app/helpers/sync-destinations.js b/ui/lib/core/app/helpers/sync-destinations.js
new file mode 100644
index 0000000000..8e7f0a6571
--- /dev/null
+++ b/ui/lib/core/app/helpers/sync-destinations.js
@@ -0,0 +1,6 @@
+/**
+ * Copyright (c) HashiCorp, Inc.
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+export { default, syncDestinations, destinationTypes, findDestination } from 'core/helpers/sync-destinations';
diff --git a/ui/lib/core/app/helpers/to-label.js b/ui/lib/core/app/helpers/to-label.js
index 699d9ed77c..3df5eae56f 100644
--- a/ui/lib/core/app/helpers/to-label.js
+++ b/ui/lib/core/app/helpers/to-label.js
@@ -3,4 +3,4 @@
* SPDX-License-Identifier: MPL-2.0
*/
-export { default } from 'core/helpers/to-label';
+export { default, toLabel } from 'core/helpers/to-label';
diff --git a/ui/lib/kv/addon/components/kv-page-header.hbs b/ui/lib/kv/addon/components/kv-page-header.hbs
index a61861bf09..dcc73621df 100644
--- a/ui/lib/kv/addon/components/kv-page-header.hbs
+++ b/ui/lib/kv/addon/components/kv-page-header.hbs
@@ -20,6 +20,10 @@
+{{#if (has-block "syncDetails")}}
+ {{yield to="syncDetails"}}
+{{/if}}
+
{{#if (has-block "tabLinks")}}