mirror of
https://github.com/optim-enterprises-bv/vault.git
synced 2025-11-02 19:47:54 +00:00
Secrets Sync (#23667)
* Ember Engine Setup for Secrets Sync (#23653) * ember engine setup for secrets sync * Update ui/lib/sync/addon/routes.js Co-authored-by: claire bontempo <68122737+hellobontempo@users.noreply.github.com> --------- Co-authored-by: claire bontempo <68122737+hellobontempo@users.noreply.github.com> * Sync Mirage Setup (#23683) * adds mirage setup for sync endpoints * updates secret_name default in sync-association mirage factory * UI Secrets Sync: Ember data sync destinations (#23674) * add models * adapters * base model adapter * update test response * add sync destinations helper * finish renaming base destination model/adapter * add comment * add serializer * use normalizeItems instead * destination serializer test * add destination find method; * add conditional operand * UI Secrets Sync: Overview landing page (#23696) * add models * adapters * base model adapter * update test response * add sync destinations helper * finish renaming base destination model/adapter * add comment * add serializer * doc-link helper * add version service * landing and overview component * overview page * add tests * UI Secrets Sync: Destinations adapter add LIST (#23716) * add models * adapters * base model adapter * update test response * add sync destinations helper * finish renaming base destination model/adapter * add comment * add serializer * doc-link helper * add version service * landing and overview component * overview page * build out serializer and adapters * update mirage * fix merge conflicts * one more conflict! * pull transformQueryResponse to separate method in adapter * move data transforming all to serializer and tests * add note to paginationd ocs docs * conditionally render CTA * add lazyPaginatedQuery method to destinations route * remove partial error * Secrets Sync: Destinations create - select type (#23792) * add category to destinations * build select type page * refactor prompt config situation * routing for destinations * update select-type routing * make card width fixed * revert CTA routing change, keep shouldRenderOverview * add header for gif demo to form * cleanup scope * more scope cleanup * add test * add type selector * rename components * rename again * remove async * fix tests * fix select type rename in test * delete renamed test * fix import of general selectors * rename using component syntax * UI Secrets Sync: Create destination form and route (#23806) * add model attribute metadata * add form and save url, remove name and type from serializer * move checkbox list to form field helper * add styling to alert inline * use newly made class * fix cancel action and cleanup form * change quotes * remove checkbox action from form component * add tests * address feedback * add API error test * use create record method instead * adapter test for create record * return from find method if type is undefined * cleanup test selectors * secrets sync: refactor sync destinations helper (#23839) * refactor getter in base destination model * add getters back to model * Secrets sync UI: Destination details page (#23842) * change labels to match params * add maskedParams to base model * add details route * add details view; * update mirage * fix secrets sync link; * delete parent destination route * add copyright header * add secrets route * move sync route outside of secrets/ route * upate mirage * export to-label * finish tests * make ternary * rename header tabs * fix selector in test * Secrets Sync UI: Cleanup headers + tabs (#23873) * remove destination header component, add headers/tabs to all routes * fix header padding * move tabs + toolbar back into component... * add copyright header * add delete modal * lol revert again * add extra line after copyright header * Secrets Sync Destinations List View (#23949) * adds route and page component for sync destinations list view * filters by type first for sync destinations * adds test for store.filterData method * Update ui/app/services/store.js Co-authored-by: Kianna <30884335+kiannaquach@users.noreply.github.com> * updates nav link label for secrets sync * moves sync destinations types out of app-types * moves loading-dropdown-option component to core addon and adds to destination list item menu * change true assertion to deepEqual in sync destinations test * adds copyright header to sync-destinations type file * clear store dataset on sync destination create --------- Co-authored-by: Kianna <30884335+kiannaquach@users.noreply.github.com> * Sync Destinations Capabilities (#23953) * adds route and page component for sync destinations list view * filters by type first for sync destinations * adds test for store.filterData method * adds capabilities checks for sync destinations * removes canList from sync destinations capabilities * updates sync header tests * Update ui/tests/integration/components/sync/sync-header-test.js Co-authored-by: claire bontempo <68122737+hellobontempo@users.noreply.github.com> * updates sync destination response serialization * updates sync destination serializer test * updates sync destinations page test assertions * fixes mirage sync destinations payload issue * removes commented out method in sync destination adapter * fixes inconsistencies with url generation for sync destinations delete * fixes sync destinations page test --------- Co-authored-by: claire bontempo <68122737+hellobontempo@users.noreply.github.com> * Sync Associations Ember Data Setup (#24132) * adds model, adapter and serializer for sync associations * updates sync association adapter save methods to use adapterOptions to determine action * Sync Destination Secrets Route and Page Component (#24155) * renames sync destination header component and adds tests * adds destination secrets route and page component * adds setup-models helper for sync testing * moves destination details test into subdir * adds destination secrets page component tests * adds controller for destination secrets route * fixes pagination route on destination secrets view * fixes sync association updated_at assertion based on timezone * updates kv secret details external route name * updates usage of old spacing style variable after merge * use confirm action instead of contextual confirm (old) component (#24189) * UI Secrets Sync: Adds secret status to kv v2 details page (#24208) * woops! missed this styling for confirm action swap * update link to go to destination secrets * change edit to view secret from destination secrets list * add synDestination to external routes for kv engine * add sync status badge component * export from addon * splaattributes * poll sync status for kv secret details and render * move from controller to component * update name to new destinationName key * reorder list view items * add refresh button * add mirage data * change to loading static * update icons to be sync specific * change name * move button and change fetch to concurrency task * add tests to kv details * add color assertion * add copyright header * small test tweaks * Update ui/tests/integration/components/sync-status-badge-test.js * fixes test --------- Co-authored-by: Jordan Reimer <zofskeez@gmail.com> * Sync Secrets to Destination (#24247) * fixes issue with filter-input debounce and updates to spread attributes for input rather than use args * adds destination sync page component * removes unused var in sync component * adds test for manual mount path input in sync view * updates mount filtering in destinations sync page to target kv v2 * Secrets Sync Landing Page Images (#24277) * updates sync landing page to add marketing images * removes top margin from sync landing-cta * adds aria-describedby to sync landing images * UI Secrets Sync: Serialize trailing slash from destination type (#24294) * remove trailing slash from type in destination LIST response * update keys in mirage and tests * Sync Overview (#24340) * updates landing-cta image to png with matching height * adds ts definitons for sync adapters * updates sync adapters and serializers to add methods for fetching overview data * adds sync associations list handler to mirage and seeds more associations in scenario * adds table and totals cards to sync overview page * adds sync overview page component tests * fixes tests * changes lastSync key to lastUpdated for sync fetchByDestinations response * adds emdash as placeholder for lastUpdated null value in secrets by destination table * updates to handle 0 associations state for destination in overview table * Secrets Sync UI: Add loading and error substates (#24353) * add error substate * add loading substates * delete loading from secrets route * Remove is-version Helper (#24388) * removes is-version helper and injects service into components * updates sync tests using version service to new API * adds comment back for tracked property in secret detials page component * updates sync tests to use common selectors (#24397) * update capitalization to consistently be titlecase, fix breadcrumb selector * clears sync associations from store on destination sync page component destroy (#24450) * KV Suggestion Input (#24447) * updates filter-input component to conditionally show search icon * adds kv-suggestion-input component to core addon * updates destination sync page component to use KvSuggestionInput component * fixes issue in kv-suggestion-input where a partial search term was not replaced with the selected suggestion value * updates kv-suggestion-input to retain focus on suggestion click * fixes test * updates kv-suggestion-input to conditionally render label component * adds comments to kv-suggestion-input regarding trigger * moves alert banner in sync page below button set * moves inputId from getter to class property on kv-suggestion-input * Secrets Sync UI: Editing a destination (#24413) * add form field groups to sync models * update create-and-edit form to use confirmLeave and enableInput component * enable input component * add more stars * update css comments * Update ui/app/styles/helper-classes/flexbox-and-grid.scss * make attrOptions optional * remove decorator * add env variables to subtexr * add subtext to textfile * fix overviwe transition bug * remove breadcrumbs to getter * WIP adapter update * update mirage response * add update method with PATCH * add patch to application adapter * fix typo * finish tests * remove validations because could use environment variables * use getter and setter in model * move update record business to serializer * rest of logic in serializer; gp ; gp * add model validation warnings * cleanup getters * pull create/update logic into method for mirage * add test for validation warning * update KV copy * Sync Success Banner (#24491) * adds success banner to destination sync page * move submit disabled logic to getter in destination sync page * adds id and for attributes to kv mount input in sync page * hides sync success banner on submit * use Sync secrets everywhere (remove new) (#24494) * use Sync secrets everywhere (remove new) * revert test name change * Sync Destinations List Filter Bug (#24496) * fixes issues filtering destinations list * adds test * fixes Sync now action text alignment in destination secrets list * UI Secrets sync: Add purge query param to delete endpoint (#24497) * adds updated_at to mirage set association handler * adds changelog entry * add enterprise in parenthesis for changelog * addres a11y feedback --------- Co-authored-by: claire bontempo <68122737+hellobontempo@users.noreply.github.com> Co-authored-by: clairebontempo@gmail.com <clairebontempo@gmail.com> Co-authored-by: Kianna <30884335+kiannaquach@users.noreply.github.com>
This commit is contained in:
3
changelog/23667.txt
Normal file
3
changelog/23667.txt
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
```release-note:feature
|
||||||
|
**Secrets Sync UI (enterprise)**: Adds secret syncing for KV v2 secrets to external destinations using the UI.
|
||||||
|
```
|
||||||
@@ -36,7 +36,7 @@ export default RESTAdapter.extend({
|
|||||||
return false;
|
return false;
|
||||||
},
|
},
|
||||||
|
|
||||||
addHeaders(url, options) {
|
addHeaders(url, options, method) {
|
||||||
const token = options.clientToken || this.auth.currentToken;
|
const token = options.clientToken || this.auth.currentToken;
|
||||||
const headers = {};
|
const headers = {};
|
||||||
if (token && !options.unauthenticated) {
|
if (token && !options.unauthenticated) {
|
||||||
@@ -45,6 +45,9 @@ export default RESTAdapter.extend({
|
|||||||
if (options.wrapTTL) {
|
if (options.wrapTTL) {
|
||||||
headers['X-Vault-Wrap-TTL'] = options.wrapTTL;
|
headers['X-Vault-Wrap-TTL'] = options.wrapTTL;
|
||||||
}
|
}
|
||||||
|
if (method === 'PATCH') {
|
||||||
|
headers['Content-Type'] = 'application/merge-patch+json';
|
||||||
|
}
|
||||||
const namespace =
|
const namespace =
|
||||||
typeof options.namespace === 'undefined' ? this.namespaceService.path : options.namespace;
|
typeof options.namespace === 'undefined' ? this.namespaceService.path : options.namespace;
|
||||||
if (namespace && !NAMESPACE_ROOT_URLS.some((str) => url.includes(str))) {
|
if (namespace && !NAMESPACE_ROOT_URLS.some((str) => url.includes(str))) {
|
||||||
@@ -53,8 +56,8 @@ export default RESTAdapter.extend({
|
|||||||
options.headers = assign(options.headers || {}, headers);
|
options.headers = assign(options.headers || {}, headers);
|
||||||
},
|
},
|
||||||
|
|
||||||
_preRequest(url, options) {
|
_preRequest(url, options, method) {
|
||||||
this.addHeaders(url, options);
|
this.addHeaders(url, options, method);
|
||||||
const isPolling = POLLING_URLS.some((str) => url.includes(str));
|
const isPolling = POLLING_URLS.some((str) => url.includes(str));
|
||||||
if (!isPolling) {
|
if (!isPolling) {
|
||||||
this.auth.setLastFetch(Date.now());
|
this.auth.setLastFetch(Date.now());
|
||||||
@@ -83,7 +86,7 @@ export default RESTAdapter.extend({
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const opts = this._preRequest(url, options);
|
const opts = this._preRequest(url, options, method);
|
||||||
|
|
||||||
return this._super(url, type, opts).then((...args) => {
|
return this._super(url, type, opts).then((...args) => {
|
||||||
if (controlGroupToken) {
|
if (controlGroupToken) {
|
||||||
|
|||||||
96
ui/app/adapters/sync/association.js
Normal file
96
ui/app/adapters/sync/association.js
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) HashiCorp, Inc.
|
||||||
|
* SPDX-License-Identifier: BUSL-1.1
|
||||||
|
*/
|
||||||
|
|
||||||
|
import ApplicationAdapter from 'vault/adapters/application';
|
||||||
|
import { assert } from '@ember/debug';
|
||||||
|
import { all } from 'rsvp';
|
||||||
|
|
||||||
|
export default class SyncAssociationAdapter extends ApplicationAdapter {
|
||||||
|
namespace = 'v1/sys/sync';
|
||||||
|
|
||||||
|
buildURL(modelName, id, snapshot, requestType, query = {}) {
|
||||||
|
const { destinationType, destinationName } = snapshot ? snapshot.attributes() : query;
|
||||||
|
if (!destinationType || !destinationName) {
|
||||||
|
return `${super.buildURL()}/associations`;
|
||||||
|
}
|
||||||
|
const { action } = snapshot?.adapterOptions || {};
|
||||||
|
const uri = action ? `/${action}` : '';
|
||||||
|
return `${super.buildURL()}/destinations/${destinationType}/${destinationName}/associations${uri}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
query(store, { modelName }, query) {
|
||||||
|
// endpoint doesn't accept the typical list query param and we don't want to pass options from lazyPaginatedQuery
|
||||||
|
const url = this.buildURL(modelName, null, null, 'query', query);
|
||||||
|
return this.ajax(url, 'GET');
|
||||||
|
}
|
||||||
|
|
||||||
|
// typically associations are queried for a specific destination which is what the standard query method does
|
||||||
|
// in specific cases we can query all associations to access total_associations and total_secrets values
|
||||||
|
queryAll() {
|
||||||
|
return this.query(this.store, { modelName: 'sync/association' }).then((response) => {
|
||||||
|
const { total_associations, total_secrets } = response.data;
|
||||||
|
return { total_associations, total_secrets };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetch associations for many destinations
|
||||||
|
// returns aggregated association information for each destination
|
||||||
|
// information includes total associations, total unsynced and most recent updated datetime
|
||||||
|
async fetchByDestinations(destinations) {
|
||||||
|
const promises = destinations.map(({ name: destinationName, type: destinationType }) => {
|
||||||
|
return this.query(this.store, { modelName: 'sync/association' }, { destinationName, destinationType });
|
||||||
|
});
|
||||||
|
const queryResponses = await all(promises);
|
||||||
|
const serializer = this.store.serializerFor('sync/association');
|
||||||
|
return queryResponses.map((response) => serializer.normalizeFetchByDestinations(response));
|
||||||
|
}
|
||||||
|
|
||||||
|
// array of association data for each destination a secret is synced to
|
||||||
|
fetchSyncStatus({ mount, secretName }) {
|
||||||
|
const url = `${this.buildURL()}/${mount}/${secretName}`;
|
||||||
|
return this.ajax(url, 'GET').then((resp) => {
|
||||||
|
const { associated_destinations } = resp.data;
|
||||||
|
const syncData = [];
|
||||||
|
for (const key in associated_destinations) {
|
||||||
|
const data = associated_destinations[key];
|
||||||
|
// renaming keys to match query() response
|
||||||
|
syncData.push({
|
||||||
|
destinationType: data.type,
|
||||||
|
destinationName: data.name,
|
||||||
|
syncStatus: data.sync_status,
|
||||||
|
updatedAt: data.updated_at,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return syncData;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// snapshot is needed for mount and secret_name values which are used to parse response since all associations are returned
|
||||||
|
_setOrRemove(store, { modelName }, snapshot) {
|
||||||
|
assert(
|
||||||
|
"action type of set or remove required when saving association => association.save({ adapterOptions: { action: 'set' }})",
|
||||||
|
['set', 'remove'].includes(snapshot?.adapterOptions?.action)
|
||||||
|
);
|
||||||
|
const url = this.buildURL(modelName, null, snapshot);
|
||||||
|
const data = snapshot.serialize();
|
||||||
|
return this.ajax(url, 'POST', { data }).then((resp) => {
|
||||||
|
const id = `${data.mount}/${data.secret_name}`;
|
||||||
|
return {
|
||||||
|
...resp.data.associated_secrets[id],
|
||||||
|
id,
|
||||||
|
destinationName: resp.data.store_name,
|
||||||
|
destinationType: resp.data.store_type,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
createRecord() {
|
||||||
|
return this._setOrRemove(...arguments);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateRecord() {
|
||||||
|
return this._setOrRemove(...arguments);
|
||||||
|
}
|
||||||
|
}
|
||||||
43
ui/app/adapters/sync/destination.js
Normal file
43
ui/app/adapters/sync/destination.js
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) HashiCorp, Inc.
|
||||||
|
* SPDX-License-Identifier: BUSL-1.1
|
||||||
|
*/
|
||||||
|
|
||||||
|
import ApplicationAdapter from 'vault/adapters/application';
|
||||||
|
import { pluralize } from 'ember-inflector';
|
||||||
|
|
||||||
|
export default class SyncDestinationAdapter extends ApplicationAdapter {
|
||||||
|
namespace = 'v1/sys';
|
||||||
|
|
||||||
|
pathForType(modelName) {
|
||||||
|
return modelName === 'sync/destination' ? pluralize(modelName) : modelName;
|
||||||
|
}
|
||||||
|
|
||||||
|
urlForCreateRecord(modelName, snapshot) {
|
||||||
|
const { name } = snapshot.attributes();
|
||||||
|
return `${super.urlForCreateRecord(modelName, snapshot)}/${name}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateRecord(store, { modelName }, snapshot) {
|
||||||
|
const { name } = snapshot.attributes();
|
||||||
|
return this.ajax(`${this.buildURL(modelName)}/${name}`, 'PATCH', { data: snapshot.serialize() });
|
||||||
|
}
|
||||||
|
|
||||||
|
urlForDeleteRecord(id, modelName, snapshot) {
|
||||||
|
const { name, type } = snapshot.attributes();
|
||||||
|
// the only delete option in the UI is to purge which unsyncs all secrets prior to deleting
|
||||||
|
return `${this.buildURL('sync/destinations')}/${type}/${name}?purge=true`;
|
||||||
|
}
|
||||||
|
|
||||||
|
query(store, { modelName }) {
|
||||||
|
return this.ajax(this.buildURL(modelName), 'GET', { data: { list: true } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// return normalized query response
|
||||||
|
// useful for fetching data directly without loading models into store
|
||||||
|
async normalizedQuery() {
|
||||||
|
const queryResponse = await this.query(this.store, { modelName: 'sync/destination' });
|
||||||
|
const serializer = this.store.serializerFor('sync/destination');
|
||||||
|
return serializer.extractLazyPaginatedData(queryResponse);
|
||||||
|
}
|
||||||
|
}
|
||||||
8
ui/app/adapters/sync/destinations/aws-sm.js
Normal file
8
ui/app/adapters/sync/destinations/aws-sm.js
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) HashiCorp, Inc.
|
||||||
|
* SPDX-License-Identifier: BUSL-1.1
|
||||||
|
*/
|
||||||
|
|
||||||
|
import SyncDestinationAdapter from '../destination';
|
||||||
|
|
||||||
|
export default class SyncDestinationsAwsSecretsManagerAdapter extends SyncDestinationAdapter {}
|
||||||
8
ui/app/adapters/sync/destinations/azure-kv.js
Normal file
8
ui/app/adapters/sync/destinations/azure-kv.js
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) HashiCorp, Inc.
|
||||||
|
* SPDX-License-Identifier: BUSL-1.1
|
||||||
|
*/
|
||||||
|
|
||||||
|
import SyncDestinationAdapter from '../destination';
|
||||||
|
|
||||||
|
export default class SyncDestinationsAzureKeyVaultAdapter extends SyncDestinationAdapter {}
|
||||||
8
ui/app/adapters/sync/destinations/gcp-sm.js
Normal file
8
ui/app/adapters/sync/destinations/gcp-sm.js
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) HashiCorp, Inc.
|
||||||
|
* SPDX-License-Identifier: BUSL-1.1
|
||||||
|
*/
|
||||||
|
|
||||||
|
import SyncDestinationAdapter from '../destination';
|
||||||
|
|
||||||
|
export default class SyncDestinationGoogleCloudSecretManagerAdapter extends SyncDestinationAdapter {}
|
||||||
8
ui/app/adapters/sync/destinations/gh.js
Normal file
8
ui/app/adapters/sync/destinations/gh.js
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) HashiCorp, Inc.
|
||||||
|
* SPDX-License-Identifier: BUSL-1.1
|
||||||
|
*/
|
||||||
|
|
||||||
|
import SyncDestinationAdapter from '../destination';
|
||||||
|
|
||||||
|
export default class SyncDestinationsGithubAdapter extends SyncDestinationAdapter {}
|
||||||
8
ui/app/adapters/sync/destinations/vercel-project.js
Normal file
8
ui/app/adapters/sync/destinations/vercel-project.js
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) HashiCorp, Inc.
|
||||||
|
* SPDX-License-Identifier: BUSL-1.1
|
||||||
|
*/
|
||||||
|
|
||||||
|
import SyncDestinationAdapter from '../destination';
|
||||||
|
|
||||||
|
export default class SyncDestinationsVercelProjectAdapter extends SyncDestinationAdapter {}
|
||||||
@@ -83,6 +83,7 @@ export default class App extends Application {
|
|||||||
],
|
],
|
||||||
externalRoutes: {
|
externalRoutes: {
|
||||||
secrets: 'vault.cluster.secrets.backends',
|
secrets: 'vault.cluster.secrets.backends',
|
||||||
|
syncDestination: 'vault.cluster.sync.secrets.destinations.destination',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -106,6 +107,15 @@ export default class App extends Application {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
sync: {
|
||||||
|
dependencies: {
|
||||||
|
services: ['flash-messages', 'router', 'store', 'version'],
|
||||||
|
externalRoutes: {
|
||||||
|
kvSecretDetails: 'vault.cluster.secrets.backend.kv.secret.details',
|
||||||
|
clientCountDashboard: 'vault.cluster.clients.dashboard',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,12 @@
|
|||||||
@text="Secrets Engines"
|
@text="Secrets Engines"
|
||||||
data-test-sidebar-nav-link="Secrets Engines"
|
data-test-sidebar-nav-link="Secrets Engines"
|
||||||
/>
|
/>
|
||||||
|
<Nav.Link
|
||||||
|
@route="vault.cluster.sync"
|
||||||
|
@text="Secrets Sync"
|
||||||
|
@badge={{unless this.version.isEnterprise "Enterprise"}}
|
||||||
|
data-test-sidebar-nav-link="Secrets Sync"
|
||||||
|
/>
|
||||||
{{#if (has-permission "access")}}
|
{{#if (has-permission "access")}}
|
||||||
<Nav.Link
|
<Nav.Link
|
||||||
@route={{get (route-params-for "access") "route"}}
|
@route={{get (route-params-for "access") "route"}}
|
||||||
|
|||||||
39
ui/app/models/sync/association.js
Normal file
39
ui/app/models/sync/association.js
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) HashiCorp, Inc.
|
||||||
|
* SPDX-License-Identifier: BUSL-1.1
|
||||||
|
*/
|
||||||
|
|
||||||
|
import Model, { attr } from '@ember-data/model';
|
||||||
|
import lazyCapabilities, { apiPath } from 'vault/macros/lazy-capabilities';
|
||||||
|
|
||||||
|
export default class SyncAssociationModel extends Model {
|
||||||
|
@attr mount;
|
||||||
|
@attr secretName;
|
||||||
|
@attr syncStatus;
|
||||||
|
@attr updatedAt;
|
||||||
|
// destination related properties that are not serialized to payload
|
||||||
|
@attr destinationName;
|
||||||
|
@attr destinationType;
|
||||||
|
|
||||||
|
@lazyCapabilities(
|
||||||
|
apiPath`sys/sync/destinations/${'destinationType'}/${'destinationName'}/associations/set`,
|
||||||
|
'destinationType',
|
||||||
|
'destinationName'
|
||||||
|
)
|
||||||
|
setAssociationPath;
|
||||||
|
|
||||||
|
@lazyCapabilities(
|
||||||
|
apiPath`sys/sync/destinations/${'destinationType'}/${'destinationName'}/associations/remove`,
|
||||||
|
'destinationType',
|
||||||
|
'destinationName'
|
||||||
|
)
|
||||||
|
removeAssociationPath;
|
||||||
|
|
||||||
|
get canSync() {
|
||||||
|
return this.setAssociationPath.get('canUpdate') !== false;
|
||||||
|
}
|
||||||
|
|
||||||
|
get canUnsync() {
|
||||||
|
return this.removeAssociationPath.get('canUpdate') !== false;
|
||||||
|
}
|
||||||
|
}
|
||||||
53
ui/app/models/sync/destination.js
Normal file
53
ui/app/models/sync/destination.js
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) HashiCorp, Inc.
|
||||||
|
* SPDX-License-Identifier: BUSL-1.1
|
||||||
|
*/
|
||||||
|
|
||||||
|
import Model, { attr } from '@ember-data/model';
|
||||||
|
import { findDestination } from 'vault/helpers/sync-destinations';
|
||||||
|
import lazyCapabilities, { apiPath } from 'vault/macros/lazy-capabilities';
|
||||||
|
import { withModelValidations } from 'vault/decorators/model-validations';
|
||||||
|
|
||||||
|
// Base model for all secret sync destination types
|
||||||
|
const validations = {
|
||||||
|
name: [{ type: 'presence', message: 'Name is required.' }],
|
||||||
|
};
|
||||||
|
|
||||||
|
@withModelValidations(validations)
|
||||||
|
export default class SyncDestinationModel extends Model {
|
||||||
|
@attr('string', { subText: 'Specifies the name for this destination.', editDisabled: true }) name;
|
||||||
|
@attr type;
|
||||||
|
|
||||||
|
// findDestination returns static attributes for each destination type
|
||||||
|
get icon() {
|
||||||
|
return findDestination(this.type)?.icon;
|
||||||
|
}
|
||||||
|
|
||||||
|
get typeDisplayName() {
|
||||||
|
return findDestination(this.type)?.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
get maskedParams() {
|
||||||
|
return findDestination(this.type)?.maskedParams;
|
||||||
|
}
|
||||||
|
|
||||||
|
@lazyCapabilities(apiPath`sys/sync/destinations/${'type'}/${'name'}`, 'type', 'name') destinationPath;
|
||||||
|
@lazyCapabilities(apiPath`sys/sync/destinations/${'type'}/${'name'}/associations/set`, 'type', 'name')
|
||||||
|
setAssociationPath;
|
||||||
|
|
||||||
|
get canCreate() {
|
||||||
|
return this.destinationPath.get('canCreate') !== false;
|
||||||
|
}
|
||||||
|
get canDelete() {
|
||||||
|
return this.destinationPath.get('canDelete') !== false;
|
||||||
|
}
|
||||||
|
get canEdit() {
|
||||||
|
return this.destinationPath.get('canUpdate') !== false;
|
||||||
|
}
|
||||||
|
get canRead() {
|
||||||
|
return this.destinationPath.get('canRead') !== false;
|
||||||
|
}
|
||||||
|
get canSync() {
|
||||||
|
return this.setAssociationPath.get('canUpdate') !== false;
|
||||||
|
}
|
||||||
|
}
|
||||||
37
ui/app/models/sync/destinations/aws-sm.js
Normal file
37
ui/app/models/sync/destinations/aws-sm.js
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) HashiCorp, Inc.
|
||||||
|
* SPDX-License-Identifier: BUSL-1.1
|
||||||
|
*/
|
||||||
|
|
||||||
|
import SyncDestinationModel from '../destination';
|
||||||
|
import { attr } from '@ember-data/model';
|
||||||
|
import { withFormFields } from 'vault/decorators/model-form-fields';
|
||||||
|
|
||||||
|
const displayFields = ['name', 'region', 'accessKeyId', 'secretAccessKey'];
|
||||||
|
const formFieldGroups = [
|
||||||
|
{ default: ['name', 'region'] },
|
||||||
|
{ Credentials: ['accessKeyId', 'secretAccessKey'] },
|
||||||
|
];
|
||||||
|
@withFormFields(displayFields, formFieldGroups)
|
||||||
|
export default class SyncDestinationsAwsSecretsManagerModel extends SyncDestinationModel {
|
||||||
|
@attr('string', {
|
||||||
|
label: 'Access key ID',
|
||||||
|
subText:
|
||||||
|
'Access key ID to authenticate against the secrets manager. If empty, Vault will use the AWS_ACCESS_KEY_ID environment variable if configured.',
|
||||||
|
})
|
||||||
|
accessKeyId; // obfuscated, never returned by API
|
||||||
|
|
||||||
|
@attr('string', {
|
||||||
|
label: 'Secret access key',
|
||||||
|
subText:
|
||||||
|
'Secret access key to authenticate against the secrets manager. If empty, Vault will use the AWS_SECRET_ACCESS_KEY environment variable if configured.',
|
||||||
|
})
|
||||||
|
secretAccessKey; // obfuscated, never returned by API
|
||||||
|
|
||||||
|
@attr('string', {
|
||||||
|
subText:
|
||||||
|
'For AWS secrets manager, the name of the region must be supplied, something like “us-west-1.” If empty, Vault will use the AWS_REGION environment variable if configured.',
|
||||||
|
editDisabled: true,
|
||||||
|
})
|
||||||
|
region;
|
||||||
|
}
|
||||||
51
ui/app/models/sync/destinations/azure-kv.js
Normal file
51
ui/app/models/sync/destinations/azure-kv.js
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) HashiCorp, Inc.
|
||||||
|
* SPDX-License-Identifier: BUSL-1.1
|
||||||
|
*/
|
||||||
|
|
||||||
|
import SyncDestinationModel from '../destination';
|
||||||
|
import { attr } from '@ember-data/model';
|
||||||
|
import { withFormFields } from 'vault/decorators/model-form-fields';
|
||||||
|
const displayFields = ['name', 'keyVaultUri', 'tenantId', 'cloud', 'clientId', 'clientSecret'];
|
||||||
|
const formFieldGroups = [
|
||||||
|
{ default: ['name', 'keyVaultUri', 'tenantId', 'cloud', 'clientId'] },
|
||||||
|
{ Credentials: ['clientSecret'] },
|
||||||
|
];
|
||||||
|
@withFormFields(displayFields, formFieldGroups)
|
||||||
|
export default class SyncDestinationsAzureKeyVaultModel extends SyncDestinationModel {
|
||||||
|
@attr('string', {
|
||||||
|
label: 'Key Vault URI',
|
||||||
|
subText:
|
||||||
|
'URI of an existing Azure Key Vault instance. If empty, Vault will use the KEY_VAULT_URI environment variable if configured.',
|
||||||
|
editDisabled: true,
|
||||||
|
})
|
||||||
|
keyVaultUri;
|
||||||
|
|
||||||
|
@attr('string', {
|
||||||
|
label: 'Client ID',
|
||||||
|
subText:
|
||||||
|
'Client ID of an Azure app registration. If empty, Vault will use the AZURE_CLIENT_ID environment variable if configured.',
|
||||||
|
})
|
||||||
|
clientId;
|
||||||
|
|
||||||
|
@attr('string', {
|
||||||
|
subText:
|
||||||
|
'Client secret of an Azure app registration. If empty, Vault will use the AZURE_CLIENT_SECRET environment variable if configured.',
|
||||||
|
})
|
||||||
|
clientSecret; // obfuscated, never returned by API
|
||||||
|
|
||||||
|
@attr('string', {
|
||||||
|
label: 'Tenant ID',
|
||||||
|
subText:
|
||||||
|
'ID of the target Azure tenant. If empty, Vault will use the AZURE_TENANT_ID environment variable if configured.',
|
||||||
|
editDisabled: true,
|
||||||
|
})
|
||||||
|
tenantId;
|
||||||
|
|
||||||
|
@attr('string', {
|
||||||
|
subText: 'Specifies a cloud for the client. The default is Azure Public Cloud.',
|
||||||
|
defaultValue: 'cloud',
|
||||||
|
editDisabled: true,
|
||||||
|
})
|
||||||
|
cloud;
|
||||||
|
}
|
||||||
24
ui/app/models/sync/destinations/gcp-sm.js
Normal file
24
ui/app/models/sync/destinations/gcp-sm.js
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) HashiCorp, Inc.
|
||||||
|
* SPDX-License-Identifier: BUSL-1.1
|
||||||
|
*/
|
||||||
|
|
||||||
|
import SyncDestinationModel from '../destination';
|
||||||
|
import { attr } from '@ember-data/model';
|
||||||
|
import { withFormFields } from 'vault/decorators/model-form-fields';
|
||||||
|
|
||||||
|
const displayFields = ['name', 'credentials'];
|
||||||
|
const formFieldGroups = [{ default: ['name'] }, { Credentials: ['credentials'] }];
|
||||||
|
@withFormFields(displayFields, formFieldGroups)
|
||||||
|
export default class SyncDestinationsGoogleCloudSecretManagerModel extends SyncDestinationModel {
|
||||||
|
@attr('string', {
|
||||||
|
label: 'JSON credentials',
|
||||||
|
subText:
|
||||||
|
'If empty, Vault will use the GOOGLE_APPLICATION_CREDENTIALS environment variable if configured.',
|
||||||
|
editType: 'file',
|
||||||
|
docLink: '/vault/docs/secrets/gcp#authentication',
|
||||||
|
})
|
||||||
|
credentials; // obfuscated, never returned by API
|
||||||
|
|
||||||
|
// TODO - confirm if project_id is going to be added to READ response (not editable)
|
||||||
|
}
|
||||||
36
ui/app/models/sync/destinations/gh.js
Normal file
36
ui/app/models/sync/destinations/gh.js
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) HashiCorp, Inc.
|
||||||
|
* SPDX-License-Identifier: BUSL-1.1
|
||||||
|
*/
|
||||||
|
|
||||||
|
import SyncDestinationModel from '../destination';
|
||||||
|
import { attr } from '@ember-data/model';
|
||||||
|
import { withFormFields } from 'vault/decorators/model-form-fields';
|
||||||
|
const displayFields = ['name', 'repositoryOwner', 'repositoryName', 'accessToken'];
|
||||||
|
const formFieldGroups = [
|
||||||
|
{ default: ['name', 'repositoryOwner', 'repositoryName'] },
|
||||||
|
{ Credentials: ['accessToken'] },
|
||||||
|
];
|
||||||
|
|
||||||
|
@withFormFields(displayFields, formFieldGroups)
|
||||||
|
export default class SyncDestinationsGithubModel extends SyncDestinationModel {
|
||||||
|
@attr('string', {
|
||||||
|
subText:
|
||||||
|
'Personal access token to authenticate to the GitHub repository. If empty, Vault will use the GITHUB_ACCESS_TOKEN environment variable if configured.',
|
||||||
|
})
|
||||||
|
accessToken; // obfuscated, never returned by API
|
||||||
|
|
||||||
|
@attr('string', {
|
||||||
|
subText:
|
||||||
|
'Github organization or username that owns the repository. If empty, Vault will use the GITHUB_REPOSITORY_OWNER environment variable if configured.',
|
||||||
|
editDisabled: true,
|
||||||
|
})
|
||||||
|
repositoryOwner;
|
||||||
|
|
||||||
|
@attr('string', {
|
||||||
|
subText:
|
||||||
|
'The name of the Github repository to connect to. If empty, Vault will use the GITHUB_REPOSITORY_NAME environment variable if configured.',
|
||||||
|
editDisabled: true,
|
||||||
|
})
|
||||||
|
repositoryName;
|
||||||
|
}
|
||||||
70
ui/app/models/sync/destinations/vercel-project.js
Normal file
70
ui/app/models/sync/destinations/vercel-project.js
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) HashiCorp, Inc.
|
||||||
|
* SPDX-License-Identifier: BUSL-1.1
|
||||||
|
*/
|
||||||
|
|
||||||
|
import SyncDestinationModel from '../destination';
|
||||||
|
import { attr } from '@ember-data/model';
|
||||||
|
import { withFormFields } from 'vault/decorators/model-form-fields';
|
||||||
|
import { withModelValidations } from 'vault/decorators/model-validations';
|
||||||
|
|
||||||
|
const validations = {
|
||||||
|
name: [{ type: 'presence', message: 'Name is required.' }],
|
||||||
|
teamId: [
|
||||||
|
{
|
||||||
|
validator: (model) =>
|
||||||
|
!model.isNew && Object.keys(model.changedAttributes()).includes('teamId') ? false : true,
|
||||||
|
message: 'Team ID should only be updated if the project was transferred to another account.',
|
||||||
|
level: 'warn',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
// getter/setter for the deploymentEnvironments model attribute
|
||||||
|
deploymentEnvironmentsArray: [{ type: 'presence', message: 'At least one environment is required.' }],
|
||||||
|
};
|
||||||
|
const displayFields = ['name', 'accessToken', 'projectId', 'teamId', 'deploymentEnvironments'];
|
||||||
|
const formFieldGroups = [
|
||||||
|
{ default: ['name', 'projectId', 'teamId', 'deploymentEnvironments'] },
|
||||||
|
{ Credentials: ['accessToken'] },
|
||||||
|
];
|
||||||
|
@withModelValidations(validations)
|
||||||
|
@withFormFields(displayFields, formFieldGroups)
|
||||||
|
export default class SyncDestinationsVercelProjectModel extends SyncDestinationModel {
|
||||||
|
@attr('string', {
|
||||||
|
subText: 'Vercel API access token with the permissions to manage environment variables.',
|
||||||
|
})
|
||||||
|
accessToken; // obfuscated, never returned by API
|
||||||
|
|
||||||
|
@attr('string', {
|
||||||
|
label: 'Project ID',
|
||||||
|
subText: 'Project ID where to manage environment variables.',
|
||||||
|
editDisabled: true,
|
||||||
|
})
|
||||||
|
projectId;
|
||||||
|
|
||||||
|
@attr('string', {
|
||||||
|
label: 'Team ID',
|
||||||
|
subText: 'Team ID the project belongs to. Optional.',
|
||||||
|
})
|
||||||
|
teamId;
|
||||||
|
|
||||||
|
// comma separated string, updated as array using deploymentEnvironmentsArray
|
||||||
|
@attr({
|
||||||
|
subText: 'Deployment environments where the environment variables are available.',
|
||||||
|
editType: 'checkboxList',
|
||||||
|
possibleValues: ['development', 'preview', 'production'],
|
||||||
|
fieldValue: 'deploymentEnvironmentsArray', // getter/setter used to update value
|
||||||
|
})
|
||||||
|
deploymentEnvironments;
|
||||||
|
|
||||||
|
// Instead of using the 'array' attr transform, we keep deploymentEnvironments a string to leverage Ember's changedAttributes()
|
||||||
|
// which only tracks updates to string types. However, arrays are easier for managing multi-option selection so
|
||||||
|
// the fieldValue is used to get/set the deploymentEnvironments attribute to/from an array
|
||||||
|
get deploymentEnvironmentsArray() {
|
||||||
|
// if undefined or an empty string, return empty array
|
||||||
|
return !this.deploymentEnvironments ? [] : this.deploymentEnvironments.split(',');
|
||||||
|
}
|
||||||
|
|
||||||
|
set deploymentEnvironmentsArray(value) {
|
||||||
|
this.deploymentEnvironments = value.join(',');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ Router.map(function () {
|
|||||||
this.route('vault', { path: '/' }, function () {
|
this.route('vault', { path: '/' }, function () {
|
||||||
this.route('cluster', { path: '/:cluster_name' }, function () {
|
this.route('cluster', { path: '/:cluster_name' }, function () {
|
||||||
this.route('dashboard');
|
this.route('dashboard');
|
||||||
|
this.mount('sync');
|
||||||
this.route('oidc-provider-ns', { path: '/*namespace/identity/oidc/provider/:provider_name/authorize' });
|
this.route('oidc-provider-ns', { path: '/*namespace/identity/oidc/provider/:provider_name/authorize' });
|
||||||
this.route('oidc-provider', { path: '/identity/oidc/provider/:provider_name/authorize' });
|
this.route('oidc-provider', { path: '/identity/oidc/provider/:provider_name/authorize' });
|
||||||
this.route('oidc-callback', { path: '/auth/*auth_path/oidc/callback' });
|
this.route('oidc-callback', { path: '/auth/*auth_path/oidc/callback' });
|
||||||
|
|||||||
64
ui/app/serializers/sync/association.js
Normal file
64
ui/app/serializers/sync/association.js
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) HashiCorp, Inc.
|
||||||
|
* SPDX-License-Identifier: BUSL-1.1
|
||||||
|
*/
|
||||||
|
|
||||||
|
import ApplicationSerializer from 'vault/serializers/application';
|
||||||
|
import { findDestination } from 'core/helpers/sync-destinations';
|
||||||
|
|
||||||
|
export default class SyncAssociationSerializer extends ApplicationSerializer {
|
||||||
|
attrs = {
|
||||||
|
destinationName: { serialize: false },
|
||||||
|
destinationType: { serialize: false },
|
||||||
|
syncStatus: { serialize: false },
|
||||||
|
updatedAt: { serialize: false },
|
||||||
|
};
|
||||||
|
|
||||||
|
extractLazyPaginatedData(payload) {
|
||||||
|
if (payload) {
|
||||||
|
const { store_name, store_type, associated_secrets } = payload.data;
|
||||||
|
const secrets = [];
|
||||||
|
for (const key in associated_secrets) {
|
||||||
|
const data = associated_secrets[key];
|
||||||
|
data.id = key;
|
||||||
|
const association = {
|
||||||
|
destinationName: store_name,
|
||||||
|
destinationType: store_type,
|
||||||
|
...data,
|
||||||
|
};
|
||||||
|
secrets.push(association);
|
||||||
|
}
|
||||||
|
return secrets;
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizeFetchByDestinations(payload) {
|
||||||
|
const { store_name, store_type, associated_secrets } = payload.data;
|
||||||
|
const unsynced = [];
|
||||||
|
let lastUpdated;
|
||||||
|
|
||||||
|
for (const key in associated_secrets) {
|
||||||
|
const association = associated_secrets[key];
|
||||||
|
// for display purposes, any status other than SYNCED is considered unsynced
|
||||||
|
if (association.sync_status !== 'SYNCED') {
|
||||||
|
unsynced.push(association.sync_status);
|
||||||
|
}
|
||||||
|
// use the most recent updated_at value as the last synced date
|
||||||
|
const updated = new Date(association.updated_at);
|
||||||
|
if (!lastUpdated || updated > lastUpdated) {
|
||||||
|
lastUpdated = updated;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const associationCount = Object.entries(associated_secrets).length;
|
||||||
|
return {
|
||||||
|
icon: findDestination(store_type).icon,
|
||||||
|
name: store_name,
|
||||||
|
type: store_type,
|
||||||
|
associationCount,
|
||||||
|
status: associationCount ? (unsynced.length ? `${unsynced.length} Unsynced` : 'All synced') : null,
|
||||||
|
lastUpdated,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
65
ui/app/serializers/sync/destination.js
Normal file
65
ui/app/serializers/sync/destination.js
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) HashiCorp, Inc.
|
||||||
|
* SPDX-License-Identifier: BUSL-1.1
|
||||||
|
*/
|
||||||
|
|
||||||
|
import ApplicationSerializer from 'vault/serializers/application';
|
||||||
|
import { decamelize } from '@ember/string';
|
||||||
|
export default class SyncDestinationSerializer extends ApplicationSerializer {
|
||||||
|
attrs = {
|
||||||
|
name: { serialize: false },
|
||||||
|
type: { serialize: false },
|
||||||
|
};
|
||||||
|
|
||||||
|
serialize(snapshot) {
|
||||||
|
// special serialization only for PATCH requests
|
||||||
|
if (snapshot.isNew) return super.serialize(snapshot);
|
||||||
|
|
||||||
|
// only send changed values
|
||||||
|
const data = {};
|
||||||
|
for (const attr in snapshot.changedAttributes()) {
|
||||||
|
// first array element is the old value
|
||||||
|
const [, newValue] = snapshot.changedAttributes()[attr];
|
||||||
|
data[decamelize(attr)] = newValue;
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// interrupt application's normalizeItems, which is called in normalizeResponse by application serializer
|
||||||
|
normalizeResponse(store, primaryModelClass, payload, id, requestType) {
|
||||||
|
const transformedPayload = this._normalizePayload(payload, requestType);
|
||||||
|
return super.normalizeResponse(store, primaryModelClass, transformedPayload, id, requestType);
|
||||||
|
}
|
||||||
|
|
||||||
|
extractLazyPaginatedData(payload) {
|
||||||
|
const transformedPayload = [];
|
||||||
|
// loop through each destination type (keys in key_info)
|
||||||
|
for (const key in payload.data.key_info) {
|
||||||
|
// iterate through each type's destination names
|
||||||
|
payload.data.key_info[key].forEach((name) => {
|
||||||
|
// remove trailing slash from key
|
||||||
|
const type = key.replace(/\/$/, '');
|
||||||
|
const id = `${type}/${name}`;
|
||||||
|
// create object with destination's id and attributes, add to payload
|
||||||
|
transformedPayload.pushObject({ id, name, type });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return transformedPayload;
|
||||||
|
}
|
||||||
|
|
||||||
|
_normalizePayload(payload, requestType) {
|
||||||
|
// if request is from lazyPaginatedQuery it will already have been extracted and meta will be set on object
|
||||||
|
// for store.query it will be the raw response which will need to be extracted
|
||||||
|
if (requestType === 'query') {
|
||||||
|
return payload.meta ? payload : this.extractLazyPaginatedData(payload);
|
||||||
|
} else if (payload?.data) {
|
||||||
|
// uses name for id and spreads connection_details object into data
|
||||||
|
const { data } = payload;
|
||||||
|
const connection_details = payload.data.connection_details || {};
|
||||||
|
data.id = data.name;
|
||||||
|
delete data.connection_details;
|
||||||
|
return { data: { ...data, ...connection_details } };
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
}
|
||||||
8
ui/app/serializers/sync/destinations/aws-sm.js
Normal file
8
ui/app/serializers/sync/destinations/aws-sm.js
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) HashiCorp, Inc.
|
||||||
|
* SPDX-License-Identifier: BUSL-1.1
|
||||||
|
*/
|
||||||
|
|
||||||
|
import SyncDestinationSerializer from '../destination';
|
||||||
|
|
||||||
|
export default class SyncDestinationsAwsSecretsManagerSerializer extends SyncDestinationSerializer {}
|
||||||
8
ui/app/serializers/sync/destinations/azure-kv.js
Normal file
8
ui/app/serializers/sync/destinations/azure-kv.js
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) HashiCorp, Inc.
|
||||||
|
* SPDX-License-Identifier: BUSL-1.1
|
||||||
|
*/
|
||||||
|
|
||||||
|
import SyncDestinationSerializer from '../destination';
|
||||||
|
|
||||||
|
export default class SyncDestinationsAzureKeyVaultSerializer extends SyncDestinationSerializer {}
|
||||||
8
ui/app/serializers/sync/destinations/gcp-sm.js
Normal file
8
ui/app/serializers/sync/destinations/gcp-sm.js
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) HashiCorp, Inc.
|
||||||
|
* SPDX-License-Identifier: BUSL-1.1
|
||||||
|
*/
|
||||||
|
|
||||||
|
import SyncDestinationSerializer from '../destination';
|
||||||
|
|
||||||
|
export default class SyncDestinationGoogleCloudSecretManagerSerializer extends SyncDestinationSerializer {}
|
||||||
8
ui/app/serializers/sync/destinations/gh.js
Normal file
8
ui/app/serializers/sync/destinations/gh.js
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) HashiCorp, Inc.
|
||||||
|
* SPDX-License-Identifier: BUSL-1.1
|
||||||
|
*/
|
||||||
|
|
||||||
|
import SyncDestinationSerializer from '../destination';
|
||||||
|
|
||||||
|
export default class SyncDestinationsGithubSerializer extends SyncDestinationSerializer {}
|
||||||
8
ui/app/serializers/sync/destinations/vercel-project.js
Normal file
8
ui/app/serializers/sync/destinations/vercel-project.js
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) HashiCorp, Inc.
|
||||||
|
* SPDX-License-Identifier: BUSL-1.1
|
||||||
|
*/
|
||||||
|
|
||||||
|
import SyncDestinationSerializer from '../destination';
|
||||||
|
|
||||||
|
export default class SyncDestinationsVercelProjectSerializer extends SyncDestinationSerializer {}
|
||||||
@@ -64,8 +64,9 @@ export default class StoreService extends Store {
|
|||||||
// the array of items will be found
|
// the array of items will be found
|
||||||
// page: the page number to return
|
// page: the page number to return
|
||||||
// size: the size of the page
|
// size: the size of the page
|
||||||
// pageFilter: a string that will be used to do a fuzzy match against the
|
// pageFilter: a string that will be used to do a fuzzy match against the results,
|
||||||
// results, this is done pre-pagination
|
// OR a function to be executed that will receive the dataset as the lone arg.
|
||||||
|
// Filter is done pre-pagination.
|
||||||
lazyPaginatedQuery(modelType, query, adapterOptions) {
|
lazyPaginatedQuery(modelType, query, adapterOptions) {
|
||||||
const skipCache = query.skipCache;
|
const skipCache = query.skipCache;
|
||||||
// We don't want skipCache to be part of the actual query key, so remove it
|
// We don't want skipCache to be part of the actual query key, so remove it
|
||||||
@@ -103,11 +104,15 @@ export default class StoreService extends Store {
|
|||||||
filterData(filter, dataset) {
|
filterData(filter, dataset) {
|
||||||
let newData = dataset || [];
|
let newData = dataset || [];
|
||||||
if (filter) {
|
if (filter) {
|
||||||
newData = dataset.filter(function (item) {
|
if (filter instanceof Function) {
|
||||||
|
newData = filter(dataset);
|
||||||
|
} else {
|
||||||
|
newData = dataset.filter((item) => {
|
||||||
const id = item.id || item.name || item;
|
const id = item.id || item.name || item;
|
||||||
return id.toLowerCase().includes(filter.toLowerCase());
|
return id.toLowerCase().includes(filter.toLowerCase());
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return newData;
|
return newData;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,4 +20,8 @@
|
|||||||
width: 6.5rem;
|
width: 6.5rem;
|
||||||
min-height: 8rem;
|
min-height: 8rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.card-width-20 {
|
||||||
|
width: 20rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,7 +44,8 @@
|
|||||||
.has-error-border,
|
.has-error-border,
|
||||||
select.has-error-border,
|
select.has-error-border,
|
||||||
.ttl-picker-form-field-error input,
|
.ttl-picker-form-field-error input,
|
||||||
.string-list-form-field-error .field:first-of-type textarea {
|
.string-list-form-field-error .field:first-of-type textarea,
|
||||||
|
.hds-form-checkbox.has-error-border {
|
||||||
border: 1px solid $red-500;
|
border: 1px solid $red-500;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -53,6 +53,10 @@
|
|||||||
padding-bottom: $spacing-24;
|
padding-bottom: $spacing-24;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.has-top-padding-xs {
|
||||||
|
padding-top: $spacing-8;
|
||||||
|
}
|
||||||
|
|
||||||
.has-top-padding-s {
|
.has-top-padding-s {
|
||||||
padding-top: $spacing-12;
|
padding-top: $spacing-12;
|
||||||
}
|
}
|
||||||
@@ -167,6 +171,10 @@
|
|||||||
margin-top: $spacing-48;
|
margin-top: $spacing-48;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.has-top-margin-xxxl {
|
||||||
|
margin-top: $spacing-64;
|
||||||
|
}
|
||||||
|
|
||||||
.has-top-margin-negative-s {
|
.has-top-margin-negative-s {
|
||||||
margin-top: (-1 * $spacing-12);
|
margin-top: (-1 * $spacing-12);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ $spacing-24: 24px;
|
|||||||
$spacing-32: 32px;
|
$spacing-32: 32px;
|
||||||
$spacing-36: 36px;
|
$spacing-36: 36px;
|
||||||
$spacing-48: 48px;
|
$spacing-48: 48px;
|
||||||
|
$spacing-64: 64px;
|
||||||
|
|
||||||
/* Border radius */
|
/* Border radius */
|
||||||
$radius: 2px;
|
$radius: 2px;
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ The `size` param defaults to the default page size set in [the app config](../co
|
|||||||
|
|
||||||
### Serializing
|
### Serializing
|
||||||
|
|
||||||
In order to interrupt the regular serialization when using `lazyPaginatedData`, define `extractLazyPaginatedData` on the modelType's serializer. This will be called with the raw response before being cached on the store.
|
In order to interrupt the regular serialization when using `lazyPaginatedData`, define `extractLazyPaginatedData` on the modelType's serializer. This will be called with the raw response before being cached on the store. `extractLazyPaginatedData` should return an array of objects.
|
||||||
|
|
||||||
## Gotchas
|
## Gotchas
|
||||||
|
|
||||||
|
|||||||
@@ -160,6 +160,17 @@ loadInitializers(App, config.modulePrefix);
|
|||||||
|
|
||||||
If you used `ember g in-repo-engine <engine-name>` to generate the engine’s blueprint, it should have added `this.mount(<engine-name>)` to the main app’s `router.js` file (this adds your engine and its associated routes). \*Move `this.mount(<engine-name>)` to match your engine’s route structure. For more information about [Routable Engines](https://ember-engines.com/docs/quickstart#routable-engines).
|
If you used `ember g in-repo-engine <engine-name>` to generate the engine’s blueprint, it should have added `this.mount(<engine-name>)` to the main app’s `router.js` file (this adds your engine and its associated routes). \*Move `this.mount(<engine-name>)` to match your engine’s route structure. For more information about [Routable Engines](https://ember-engines.com/docs/quickstart#routable-engines).
|
||||||
|
|
||||||
|
## Add engine path to ember-addon section of main app package.json
|
||||||
|
|
||||||
|
```json
|
||||||
|
"ember-addon": {
|
||||||
|
"paths": [
|
||||||
|
"lib/core",
|
||||||
|
"lib/your-new-engine"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
### Important Notes:
|
### Important Notes:
|
||||||
|
|
||||||
- Anytime a new engine is created, you will need to `yarn install` and **RESTART** ember server!
|
- Anytime a new engine is created, you will need to `yarn install` and **RESTART** ember server!
|
||||||
|
|||||||
@@ -3,16 +3,9 @@
|
|||||||
SPDX-License-Identifier: BUSL-1.1
|
SPDX-License-Identifier: BUSL-1.1
|
||||||
~}}
|
~}}
|
||||||
|
|
||||||
<div class="field">
|
<div class="control {{unless @hideIcon 'has-icons-left'}}" data-test-filter-input-container>
|
||||||
<p class="control has-icons-left">
|
<input class="filter input" ...attributes {{on "input" this.onInput}} {{did-insert this.focus}} data-test-filter-input />
|
||||||
<input
|
{{#unless @hideIcon}}
|
||||||
class="filter input"
|
<Icon @name="search" class="search-icon has-text-grey-light" data-test-filter-input-icon />
|
||||||
placeholder={{this.placeholder}}
|
{{/unless}}
|
||||||
data-test-filter-input
|
|
||||||
value={{this.value}}
|
|
||||||
{{on "input" this.onInput}}
|
|
||||||
{{did-insert this.focus}}
|
|
||||||
/>
|
|
||||||
<Icon @name="search" class="search-icon has-text-grey-light" />
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* Copyright (c) HashiCorp, Inc.
|
* Copyright (c) HashiCorp, Inc.
|
||||||
* SPDX-License-Identifier: MPL-2.0
|
* SPDX-License-Identifier: BUSL-1.1
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import Component from '@glimmer/component';
|
import Component from '@glimmer/component';
|
||||||
@@ -10,25 +10,13 @@ import { debounce, next } from '@ember/runloop';
|
|||||||
import type { HTMLElementEvent } from 'vault/forms';
|
import type { HTMLElementEvent } from 'vault/forms';
|
||||||
|
|
||||||
interface Args {
|
interface Args {
|
||||||
value?: string; // initial value
|
wait?: number; // defaults to 500
|
||||||
placeholder?: string; // defaults to Type to filter results
|
|
||||||
wait?: number; // defaults to 200
|
|
||||||
autofocus?: boolean; // initially focus the input on did-insert
|
autofocus?: boolean; // initially focus the input on did-insert
|
||||||
onInput(value: string): void;
|
hideIcon?: boolean; // hide the search icon in the input
|
||||||
|
onInput(value: string): void; // invoked with input value after debounce timer expires
|
||||||
}
|
}
|
||||||
|
|
||||||
export default class FilterInputComponent extends Component<Args> {
|
export default class FilterInputComponent extends Component<Args> {
|
||||||
value: string | undefined;
|
|
||||||
|
|
||||||
constructor(owner: unknown, args: Args) {
|
|
||||||
super(owner, args);
|
|
||||||
this.value = this.args.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
get placeholder() {
|
|
||||||
return this.args.placeholder || 'Type to filter results';
|
|
||||||
}
|
|
||||||
|
|
||||||
@action
|
@action
|
||||||
focus(elem: HTMLElement) {
|
focus(elem: HTMLElement) {
|
||||||
if (this.args.autofocus) {
|
if (this.args.autofocus) {
|
||||||
@@ -38,11 +26,10 @@ export default class FilterInputComponent extends Component<Args> {
|
|||||||
|
|
||||||
@action
|
@action
|
||||||
onInput(event: HTMLElementEvent<HTMLInputElement>) {
|
onInput(event: HTMLElementEvent<HTMLInputElement>) {
|
||||||
const callback = () => {
|
const wait = this.args.wait || 500;
|
||||||
this.args.onInput(event.target.value);
|
|
||||||
};
|
|
||||||
const wait = this.args.wait || 200;
|
|
||||||
// ts complains when trying to pass object of optional args to callback as 3rd arg to debounce
|
// ts complains when trying to pass object of optional args to callback as 3rd arg to debounce
|
||||||
debounce(this, callback, wait);
|
// eslint-disable-next-line
|
||||||
|
// @ts-ignore
|
||||||
|
debounce(this, this.args.onInput, event.target.value, wait);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,21 @@
|
|||||||
</div>
|
</div>
|
||||||
{{/each}}
|
{{/each}}
|
||||||
</div>
|
</div>
|
||||||
|
{{else if (eq @attr.options.editType "checkboxList")}}
|
||||||
|
<Hds::Form::Checkbox::Group @name={{@attr.name}} data-test-input={{@attr.name}} as |G|>
|
||||||
|
{{#each @attr.options.possibleValues as |option|}}
|
||||||
|
<G.Checkbox::Field
|
||||||
|
class={{if this.validationError "has-error-border"}}
|
||||||
|
checked={{includes option (get @model this.valuePath)}}
|
||||||
|
@value={{option}}
|
||||||
|
@id={{option}}
|
||||||
|
{{on "change" this.handleChecklist}}
|
||||||
|
as |F|
|
||||||
|
>
|
||||||
|
<F.Label>{{option}}</F.Label>
|
||||||
|
</G.Checkbox::Field>
|
||||||
|
{{/each}}
|
||||||
|
</Hds::Form::Checkbox::Group>
|
||||||
{{else}}
|
{{else}}
|
||||||
<div class="control is-expanded">
|
<div class="control is-expanded">
|
||||||
<div class="select is-fullwidth">
|
<div class="select is-fullwidth">
|
||||||
@@ -101,7 +116,7 @@
|
|||||||
/>
|
/>
|
||||||
{{else if (eq @attr.options.editType "file")}}
|
{{else if (eq @attr.options.editType "file")}}
|
||||||
{{! File Input }}
|
{{! File Input }}
|
||||||
<div class="has-bottom-margin-m">
|
<div class="has-bottom-margin-m" data-test-input={{@attr.name}}>
|
||||||
<TextFile
|
<TextFile
|
||||||
@label={{this.labelString}}
|
@label={{this.labelString}}
|
||||||
@subText={{@attr.options.subText}}
|
@subText={{@attr.options.subText}}
|
||||||
@@ -325,7 +340,7 @@
|
|||||||
@type="danger"
|
@type="danger"
|
||||||
@message={{this.validationError}}
|
@message={{this.validationError}}
|
||||||
@paddingTop={{not-eq @attr.options.editType "ttl"}}
|
@paddingTop={{not-eq @attr.options.editType "ttl"}}
|
||||||
data-test-field-validation={{@attr.name}}
|
data-test-field-validation={{this.valuePath}}
|
||||||
class={{if (eq @attr.options.editType "stringArray") "has-top-margin-negative-xxl"}}
|
class={{if (eq @attr.options.editType "stringArray") "has-top-margin-negative-xxl"}}
|
||||||
/>
|
/>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
@@ -334,7 +349,7 @@
|
|||||||
@type="warning"
|
@type="warning"
|
||||||
@message={{this.validationWarning}}
|
@message={{this.validationWarning}}
|
||||||
@paddingTop={{if (and (not this.validationError) (eq @attr.options.editType "ttl")) false true}}
|
@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"}}
|
class={{if (and (not this.validationError) (eq @attr.options.editType "stringArray")) "has-top-margin-negative-xxl"}}
|
||||||
/>
|
/>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
|
|||||||
@@ -184,4 +184,12 @@ export default class FormFieldComponent extends Component {
|
|||||||
const prop = event.target.type === 'checkbox' ? 'checked' : 'value';
|
const prop = event.target.type === 'checkbox' ? 'checked' : 'value';
|
||||||
this.setAndBroadcast(event.target[prop]);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
35
ui/lib/core/addon/components/kv-suggestion-input.hbs
Normal file
35
ui/lib/core/addon/components/kv-suggestion-input.hbs
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
{{!
|
||||||
|
Copyright (c) HashiCorp, Inc.
|
||||||
|
SPDX-License-Identifier: BUSL-1.1
|
||||||
|
~}}
|
||||||
|
<div ...attributes {{did-update this.updateSuggestions @mountPath}}>
|
||||||
|
{{#if @label}}
|
||||||
|
<FormFieldLabel for={{this.inputId}} @label={{@label}} @subText={{@subText}} />
|
||||||
|
{{/if}}
|
||||||
|
<FilterInput
|
||||||
|
id={{this.inputId}}
|
||||||
|
placeholder="Path to secret"
|
||||||
|
value={{@value}}
|
||||||
|
disabled={{not @mountPath}}
|
||||||
|
@hideIcon={{true}}
|
||||||
|
@onInput={{this.onInput}}
|
||||||
|
{{! used to trigger dropdown to open }}
|
||||||
|
{{on "click" this.onInputClick}}
|
||||||
|
data-test-kv-suggestion-input
|
||||||
|
/>
|
||||||
|
<PowerSelect
|
||||||
|
@eventType="click"
|
||||||
|
@options={{this.secrets}}
|
||||||
|
@onChange={{this.onSuggestionSelect}}
|
||||||
|
@renderInPlace={{true}}
|
||||||
|
@disabled={{not @mountPath}}
|
||||||
|
@registerAPI={{fn (mut this.powerSelectAPI)}}
|
||||||
|
{{! hide trigger component and use API to toggle dropdown }}
|
||||||
|
@triggerClass="is-hidden"
|
||||||
|
@noMatchesMessage="No suggestions for this path"
|
||||||
|
data-test-kv-suggestion-select
|
||||||
|
as |secret|
|
||||||
|
>
|
||||||
|
{{secret.path}}
|
||||||
|
</PowerSelect>
|
||||||
|
</div>
|
||||||
145
ui/lib/core/addon/components/kv-suggestion-input.ts
Normal file
145
ui/lib/core/addon/components/kv-suggestion-input.ts
Normal file
@@ -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
|
||||||
|
* <KvSuggestionInput
|
||||||
|
@label="Select a secret to sync"
|
||||||
|
@subText="Enter the full path to the secret. Suggestions will display below if permitted by policy."
|
||||||
|
@value={{this.secretPath}}
|
||||||
|
@mountPath={{this.mountPath}} // input disabled when mount path is not provided
|
||||||
|
@onChange={{fn (mut this.secretPath)}}
|
||||||
|
/>
|
||||||
|
*/
|
||||||
|
|
||||||
|
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<Args> {
|
||||||
|
@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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@
|
|||||||
@iconPosition="trailing"
|
@iconPosition="trailing"
|
||||||
@text={{@actionText}}
|
@text={{@actionText}}
|
||||||
@route={{@actionTo}}
|
@route={{@actionTo}}
|
||||||
|
@isRouteExternal={{@actionExternal}}
|
||||||
@query={{@actionQuery}}
|
@query={{@actionQuery}}
|
||||||
data-test-action-text={{@actionText}}
|
data-test-action-text={{@actionText}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
6
ui/lib/core/addon/components/sync-status-badge.hbs
Normal file
6
ui/lib/core/addon/components/sync-status-badge.hbs
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{{!
|
||||||
|
Copyright (c) HashiCorp, Inc.
|
||||||
|
SPDX-License-Identifier: BUSL-1.1
|
||||||
|
~}}
|
||||||
|
|
||||||
|
<Hds::Badge @text={{to-label @status}} @icon={{this.state.icon}} @color={{this.state.color}} ...attributes />
|
||||||
62
ui/lib/core/addon/components/sync-status-badge.ts
Normal file
62
ui/lib/core/addon/components/sync-status-badge.ts
Normal file
@@ -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<Args> {
|
||||||
|
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',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
66
ui/lib/core/addon/helpers/sync-destinations.ts
Normal file
66
ui/lib/core/addon/helpers/sync-destinations.ts
Normal file
@@ -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<SyncDestination> = [
|
||||||
|
{
|
||||||
|
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<SyncDestination> {
|
||||||
|
return [...SYNC_DESTINATIONS];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function destinationTypes(): Array<SyncDestinationType> {
|
||||||
|
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);
|
||||||
6
ui/lib/core/app/components/kv-suggestion-input.js
Normal file
6
ui/lib/core/app/components/kv-suggestion-input.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) HashiCorp, Inc.
|
||||||
|
* SPDX-License-Identifier: BUSL-1.1
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { default } from 'core/components/kv-suggestion-input';
|
||||||
6
ui/lib/core/app/components/sync-status-badge.js
Normal file
6
ui/lib/core/app/components/sync-status-badge.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) HashiCorp, Inc.
|
||||||
|
* SPDX-License-Identifier: BUSL-1.1
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { default } from 'core/components/sync-status-badge';
|
||||||
6
ui/lib/core/app/helpers/sync-destinations.js
Normal file
6
ui/lib/core/app/helpers/sync-destinations.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) HashiCorp, Inc.
|
||||||
|
* SPDX-License-Identifier: BUSL-1.1
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { default, syncDestinations, destinationTypes, findDestination } from 'core/helpers/sync-destinations';
|
||||||
@@ -3,4 +3,4 @@
|
|||||||
* SPDX-License-Identifier: MPL-2.0
|
* SPDX-License-Identifier: MPL-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export { default } from 'core/helpers/to-label';
|
export { default, toLabel } from 'core/helpers/to-label';
|
||||||
|
|||||||
@@ -20,6 +20,10 @@
|
|||||||
</p.levelLeft>
|
</p.levelLeft>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
|
{{#if (has-block "syncDetails")}}
|
||||||
|
{{yield to="syncDetails"}}
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
{{#if (has-block "tabLinks")}}
|
{{#if (has-block "tabLinks")}}
|
||||||
<div class="tabs-container box is-bottomless is-marginless is-paddingless">
|
<div class="tabs-container box is-bottomless is-marginless is-paddingless">
|
||||||
<nav class="tabs" aria-label="kv tabs">
|
<nav class="tabs" aria-label="kv tabs">
|
||||||
|
|||||||
@@ -4,6 +4,46 @@
|
|||||||
~}}
|
~}}
|
||||||
|
|
||||||
<KvPageHeader @breadcrumbs={{@breadcrumbs}} @pageTitle={{@path}}>
|
<KvPageHeader @breadcrumbs={{@breadcrumbs}} @pageTitle={{@path}}>
|
||||||
|
<:syncDetails>
|
||||||
|
{{#if this.syncStatus}}
|
||||||
|
<Hds::Alert data-test-sync-alert @type="page" @color="neutral" @icon={{false}} as |A|>
|
||||||
|
<A.Title>
|
||||||
|
This secret has been synced from Vault to other destinations, updates to the secret will get automatically synced
|
||||||
|
to destinations.
|
||||||
|
</A.Title>
|
||||||
|
|
||||||
|
{{#each this.syncStatus as |status|}}
|
||||||
|
<A.Description data-test-sync-alert={{status.destinationName}}>
|
||||||
|
<SyncStatusBadge @status={{status.syncStatus}} />
|
||||||
|
<Hds::Link::Inline
|
||||||
|
@route="syncDestination"
|
||||||
|
@color="secondary"
|
||||||
|
@isRouteExternal={{true}}
|
||||||
|
@models={{array status.destinationType status.destinationName}}
|
||||||
|
>
|
||||||
|
{{status.destinationName}}
|
||||||
|
</Hds::Link::Inline>
|
||||||
|
- last updated
|
||||||
|
{{date-format status.updatedAt "MMMM do yyyy, h:mm:ss a"}}
|
||||||
|
</A.Description>
|
||||||
|
{{/each}}
|
||||||
|
|
||||||
|
<A.Button
|
||||||
|
@icon={{if this.fetchSyncStatus.isRunning "loading"}}
|
||||||
|
@text="Refresh"
|
||||||
|
@color="secondary"
|
||||||
|
{{on "click" (perform this.fetchSyncStatus)}}
|
||||||
|
/>
|
||||||
|
<A.Link::Standalone
|
||||||
|
@isHrefExternal={{true}}
|
||||||
|
@icon="docs-link"
|
||||||
|
@text="About sync statuses"
|
||||||
|
@href={{doc-link "/vault/docs/sync#sync-statuses"}}
|
||||||
|
/>
|
||||||
|
</Hds::Alert>
|
||||||
|
{{/if}}
|
||||||
|
</:syncDetails>
|
||||||
|
|
||||||
<:tabLinks>
|
<:tabLinks>
|
||||||
<LinkTo @route="secret.details" data-test-secrets-tab="Secret">Secret</LinkTo>
|
<LinkTo @route="secret.details" data-test-secrets-tab="Secret">Secret</LinkTo>
|
||||||
<LinkTo @route="secret.metadata.index" data-test-secrets-tab="Metadata">Metadata</LinkTo>
|
<LinkTo @route="secret.metadata.index" data-test-secrets-tab="Metadata">Metadata</LinkTo>
|
||||||
|
|||||||
@@ -35,10 +35,12 @@ export default class KvSecretDetails extends Component {
|
|||||||
|
|
||||||
@tracked showJsonView = false;
|
@tracked showJsonView = false;
|
||||||
@tracked wrappedData = null;
|
@tracked wrappedData = null;
|
||||||
|
@tracked syncStatus = null; // array of association sync status info by destination
|
||||||
secretDataIsAdvanced;
|
secretDataIsAdvanced;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super(...arguments);
|
super(...arguments);
|
||||||
|
this.fetchSyncStatus.perform();
|
||||||
this.originalSecret = JSON.stringify(this.args.secret.secretData || {});
|
this.originalSecret = JSON.stringify(this.args.secret.secretData || {});
|
||||||
if (this.originalSecret.lastIndexOf('{') > 0) {
|
if (this.originalSecret.lastIndexOf('{') > 0) {
|
||||||
// Dumb way to check if there's a nested object in the secret
|
// Dumb way to check if there's a nested object in the secret
|
||||||
@@ -73,6 +75,18 @@ export default class KvSecretDetails extends Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@task
|
||||||
|
@waitFor
|
||||||
|
*fetchSyncStatus() {
|
||||||
|
const { backend, path } = this.args.secret;
|
||||||
|
const syncAdapter = this.store.adapterFor('sync/association');
|
||||||
|
try {
|
||||||
|
this.syncStatus = yield syncAdapter.fetchSyncStatus({ mount: backend, secretName: path });
|
||||||
|
} catch (e) {
|
||||||
|
// silently error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
async undelete() {
|
async undelete() {
|
||||||
const { secret } = this.args;
|
const { secret } = this.args;
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export default class KvEngine extends Engine {
|
|||||||
'flash-messages',
|
'flash-messages',
|
||||||
'control-group',
|
'control-group',
|
||||||
],
|
],
|
||||||
externalRoutes: ['secrets'],
|
externalRoutes: ['secrets', 'syncDestination'],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ export default class KvSecretDetailsIndexRoute extends Route {
|
|||||||
{ label: resolvedModel.backend, route: 'list' },
|
{ label: resolvedModel.backend, route: 'list' },
|
||||||
...breadcrumbsForSecret(resolvedModel.path, true),
|
...breadcrumbsForSecret(resolvedModel.path, true),
|
||||||
];
|
];
|
||||||
|
|
||||||
controller.breadcrumbs = breadcrumbsArray;
|
controller.breadcrumbs = breadcrumbsArray;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,12 @@
|
|||||||
<TabPageHeader @model={{@backendModel}} @breadcrumbs={{@breadcrumbs}}>
|
<TabPageHeader @model={{@backendModel}} @breadcrumbs={{@breadcrumbs}}>
|
||||||
<:toolbarFilters>
|
<:toolbarFilters>
|
||||||
{{#if (and (not @promptConfig) @libraries)}}
|
{{#if (and (not @promptConfig) @libraries)}}
|
||||||
<FilterInput @placeholder="Filter libraries" @onInput={{fn (mut this.filterValue)}} />
|
<FilterInput
|
||||||
|
placeholder="Filter libraries"
|
||||||
|
value={{this.filterValue}}
|
||||||
|
@wait={{200}}
|
||||||
|
@onInput={{fn (mut this.filterValue)}}
|
||||||
|
/>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
</:toolbarFilters>
|
</:toolbarFilters>
|
||||||
<:toolbarActions>
|
<:toolbarActions>
|
||||||
|
|||||||
@@ -6,13 +6,7 @@
|
|||||||
<TabPageHeader @model={{@backendModel}} @breadcrumbs={{@breadcrumbs}}>
|
<TabPageHeader @model={{@backendModel}} @breadcrumbs={{@breadcrumbs}}>
|
||||||
<:toolbarFilters>
|
<:toolbarFilters>
|
||||||
{{#if (and (not @promptConfig) @roles.meta.total)}}
|
{{#if (and (not @promptConfig) @roles.meta.total)}}
|
||||||
<FilterInput
|
<FilterInput placeholder="Filter roles" value={{@pageFilter}} @autofocus={{true}} @onInput={{this.onFilterChange}} />
|
||||||
@placeholder="Filter roles"
|
|
||||||
@value={{@pageFilter}}
|
|
||||||
@wait={{500}}
|
|
||||||
@autofocus={{true}}
|
|
||||||
@onInput={{this.onFilterChange}}
|
|
||||||
/>
|
|
||||||
{{/if}}
|
{{/if}}
|
||||||
</:toolbarFilters>
|
</:toolbarFilters>
|
||||||
<:toolbarActions>
|
<:toolbarActions>
|
||||||
|
|||||||
75
ui/lib/sync/addon/components/secrets/destination-header.hbs
Normal file
75
ui/lib/sync/addon/components/secrets/destination-header.hbs
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
{{!
|
||||||
|
Copyright (c) HashiCorp, Inc.
|
||||||
|
SPDX-License-Identifier: BUSL-1.1
|
||||||
|
~}}
|
||||||
|
|
||||||
|
<SyncHeader
|
||||||
|
@icon={{@destination.icon}}
|
||||||
|
@title={{@destination.name}}
|
||||||
|
@breadcrumbs={{array
|
||||||
|
(hash label="Secrets Sync" route="secrets.overview")
|
||||||
|
(hash label="Destinations" route="secrets.destinations")
|
||||||
|
(hash label="Destination")
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="tabs-container box is-bottomless is-marginless is-paddingless">
|
||||||
|
<nav class="tabs" aria-label="destination tabs">
|
||||||
|
<ul>
|
||||||
|
<LinkTo @route="secrets.destinations.destination.secrets" data-test-tab="Secrets">Secrets</LinkTo>
|
||||||
|
<LinkTo @route="secrets.destinations.destination.details" data-test-tab="Details">Details</LinkTo>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Toolbar>
|
||||||
|
<ToolbarActions>
|
||||||
|
{{#if @destination.canDelete}}
|
||||||
|
<Hds::Button
|
||||||
|
data-test-toolbar="Delete destination"
|
||||||
|
@text="Delete destination"
|
||||||
|
@color="secondary"
|
||||||
|
class="toolbar-button"
|
||||||
|
{{on "click" (fn (mut this.isDeleteModalOpen) true)}}
|
||||||
|
/>
|
||||||
|
{{#if (or @destination.canSync @destination.canEdit)}}
|
||||||
|
<div class="toolbar-separator"></div>
|
||||||
|
{{/if}}
|
||||||
|
{{/if}}
|
||||||
|
{{#if @destination.canSync}}
|
||||||
|
<Hds::Button
|
||||||
|
data-test-toolbar="Sync secrets"
|
||||||
|
@text="Sync secrets"
|
||||||
|
@icon="chevron-right"
|
||||||
|
@iconPosition="trailing"
|
||||||
|
@color="secondary"
|
||||||
|
class="toolbar-button"
|
||||||
|
@route="secrets.destinations.destination.sync"
|
||||||
|
/>
|
||||||
|
{{/if}}
|
||||||
|
{{#if @destination.canEdit}}
|
||||||
|
<Hds::Button
|
||||||
|
data-test-toolbar="Edit destination"
|
||||||
|
@text="Edit destination"
|
||||||
|
@icon="chevron-right"
|
||||||
|
@iconPosition="trailing"
|
||||||
|
@color="secondary"
|
||||||
|
class="toolbar-button"
|
||||||
|
@route="secrets.destinations.destination.edit"
|
||||||
|
/>
|
||||||
|
{{/if}}
|
||||||
|
</ToolbarActions>
|
||||||
|
</Toolbar>
|
||||||
|
|
||||||
|
<ConfirmationModal
|
||||||
|
@title="Delete destination?"
|
||||||
|
@onClose={{fn (mut this.isDeleteModalOpen) false}}
|
||||||
|
@isActive={{this.isDeleteModalOpen}}
|
||||||
|
@confirmText="DELETE"
|
||||||
|
@toConfirmMsg="— this is case-sensitive."
|
||||||
|
@onConfirm={{this.deleteDestination}}
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
The destination will be permanently deleted and all of its secrets will be un-synced. This cannot be undone.
|
||||||
|
</p>
|
||||||
|
</ConfirmationModal>
|
||||||
38
ui/lib/sync/addon/components/secrets/destination-header.ts
Normal file
38
ui/lib/sync/addon/components/secrets/destination-header.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) HashiCorp, Inc.
|
||||||
|
* SPDX-License-Identifier: BUSL-1.1
|
||||||
|
*/
|
||||||
|
|
||||||
|
import Component from '@glimmer/component';
|
||||||
|
import { action } from '@ember/object';
|
||||||
|
import { inject as service } from '@ember/service';
|
||||||
|
import errorMessage from 'vault/utils/error-message';
|
||||||
|
|
||||||
|
import type SyncDestinationModel from 'vault/models/sync/destination';
|
||||||
|
import type RouterService from '@ember/routing/router-service';
|
||||||
|
import type StoreService from 'vault/services/store';
|
||||||
|
import type FlashMessageService from 'vault/services/flash-messages';
|
||||||
|
|
||||||
|
interface Args {
|
||||||
|
destination: SyncDestinationModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class DestinationsTabsToolbar extends Component<Args> {
|
||||||
|
@service declare readonly router: RouterService;
|
||||||
|
@service declare readonly store: StoreService;
|
||||||
|
@service declare readonly flashMessages: FlashMessageService;
|
||||||
|
|
||||||
|
@action
|
||||||
|
async deleteDestination() {
|
||||||
|
try {
|
||||||
|
const { destination } = this.args;
|
||||||
|
const message = `Successfully deleted destination ${destination.name}.`;
|
||||||
|
await destination.destroyRecord();
|
||||||
|
this.store.clearDataset('sync/destination');
|
||||||
|
this.router.transitionTo('vault.cluster.sync.secrets.destinations');
|
||||||
|
this.flashMessages.success(message);
|
||||||
|
} catch (error) {
|
||||||
|
this.flashMessages.danger(`Error deleting destination \n ${errorMessage(error)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
48
ui/lib/sync/addon/components/secrets/landing-cta.hbs
Normal file
48
ui/lib/sync/addon/components/secrets/landing-cta.hbs
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
{{!
|
||||||
|
Copyright (c) HashiCorp, Inc.
|
||||||
|
SPDX-License-Identifier: BUSL-1.1
|
||||||
|
~}}
|
||||||
|
|
||||||
|
<div class="box is-fullwidth is-sideless is-flex-between is-shadowless" data-test-cta-container>
|
||||||
|
{{#if this.version.isEnterprise}}
|
||||||
|
<p>
|
||||||
|
Sync secrets to platforms and tools across your stack to get secrets when and where you need them.
|
||||||
|
<Hds::Link::Standalone
|
||||||
|
@icon="learn-link"
|
||||||
|
@text="Secrets sync tutorial"
|
||||||
|
@href={{doc-link "/vault/tutorials/enterprise/secrets-sync"}}
|
||||||
|
data-test-cta-doc-link
|
||||||
|
/>
|
||||||
|
</p>
|
||||||
|
{{else}}
|
||||||
|
<p>
|
||||||
|
This enterprise feature allows you to sync secrets to platforms and tools across your stack to get secrets when and
|
||||||
|
where you need them.
|
||||||
|
</p>
|
||||||
|
<Hds::Button
|
||||||
|
@text="Learn more about secrets sync"
|
||||||
|
@icon="docs-link"
|
||||||
|
@iconPosition="trailing"
|
||||||
|
@isHrefExternal={{true}}
|
||||||
|
@href={{doc-link "/vault/tutorials/enterprise/secrets-sync"}}
|
||||||
|
data-test-cta-doc-link
|
||||||
|
/>
|
||||||
|
{{/if}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="is-flex-row has-gap-l has-bottom-margin-m">
|
||||||
|
<div>
|
||||||
|
<img src={{img-path "~/sync-landing-1.png"}} alt="Secrets sync destinations diagram" aria-describedby="sync-step-1" />
|
||||||
|
<p id="sync-step-1" class="has-top-margin-m">
|
||||||
|
<b>Step 1:</b>
|
||||||
|
Create a destination, and set up the connection details to allow Vault access.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<img src={{img-path "~/sync-landing-2.png"}} alt="Syncing secrets diagram" aria-describedby="sync-step-2" />
|
||||||
|
<p id="sync-step-2" class="has-top-margin-m">
|
||||||
|
<b>Step 2:</b>
|
||||||
|
Select secrets from your KV v2 engine and sync secrets to your destination.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
13
ui/lib/sync/addon/components/secrets/landing-cta.ts
Normal file
13
ui/lib/sync/addon/components/secrets/landing-cta.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) HashiCorp, Inc.
|
||||||
|
* SPDX-License-Identifier: BUSL-1.1
|
||||||
|
*/
|
||||||
|
|
||||||
|
import Component from '@glimmer/component';
|
||||||
|
import { inject as service } from '@ember/service';
|
||||||
|
|
||||||
|
import type VersionService from 'vault/services/version';
|
||||||
|
|
||||||
|
export default class SyncLandingCtaComponent extends Component {
|
||||||
|
@service declare readonly version: VersionService;
|
||||||
|
}
|
||||||
124
ui/lib/sync/addon/components/secrets/page/destinations.hbs
Normal file
124
ui/lib/sync/addon/components/secrets/page/destinations.hbs
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
{{!
|
||||||
|
Copyright (c) HashiCorp, Inc.
|
||||||
|
SPDX-License-Identifier: BUSL-1.1
|
||||||
|
~}}
|
||||||
|
|
||||||
|
<SyncHeader @title="Secrets Sync" />
|
||||||
|
|
||||||
|
<div class="tabs-container box is-bottomless is-marginless is-paddingless">
|
||||||
|
<nav class="tabs" aria-label="destination tabs">
|
||||||
|
<ul>
|
||||||
|
<LinkTo @route="secrets.overview" data-test-tab="Overview">Overview</LinkTo>
|
||||||
|
<LinkTo @route="secrets.destinations" data-test-tab="Destinations">Destinations</LinkTo>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Toolbar>
|
||||||
|
<ToolbarFilters>
|
||||||
|
<SearchSelect
|
||||||
|
@options={{this.destinationTypes}}
|
||||||
|
@objectKeys={{array "id" "name"}}
|
||||||
|
@passObject={{true}}
|
||||||
|
@selectLimit={{1}}
|
||||||
|
@disallowNewItems={{true}}
|
||||||
|
@placeholder="Filter by type"
|
||||||
|
@inputValue={{if this.typeFilter (array this.typeFilter)}}
|
||||||
|
@onChange={{fn this.onFilterChange "type"}}
|
||||||
|
class="is-marginless"
|
||||||
|
data-test-filter="type"
|
||||||
|
/>
|
||||||
|
<SearchSelect
|
||||||
|
@options={{this.destinationNames}}
|
||||||
|
@objectKeys={{array "id" "name"}}
|
||||||
|
@passObject={{true}}
|
||||||
|
@selectLimit={{1}}
|
||||||
|
@disallowNewItems={{true}}
|
||||||
|
@placeholder="Filter by name"
|
||||||
|
@inputValue={{if @nameFilter (array @nameFilter)}}
|
||||||
|
@onChange={{fn this.onFilterChange "name"}}
|
||||||
|
class="is-marginless has-left-padding-s"
|
||||||
|
data-test-filter="name"
|
||||||
|
/>
|
||||||
|
</ToolbarFilters>
|
||||||
|
<ToolbarActions>
|
||||||
|
<ToolbarLink @route="secrets.destinations.create" @type="add" data-test-create-destination>
|
||||||
|
Create new destination
|
||||||
|
</ToolbarLink>
|
||||||
|
</ToolbarActions>
|
||||||
|
</Toolbar>
|
||||||
|
|
||||||
|
{{#if @destinations.meta.filteredTotal}}
|
||||||
|
<div class="has-bottom-margin-s">
|
||||||
|
{{#each @destinations as |destination index|}}
|
||||||
|
<ListItem
|
||||||
|
@linkPrefix={{this.mountPoint}}
|
||||||
|
@linkParams={{array "secrets.destinations.destination.secrets" destination.type destination.name}}
|
||||||
|
as |Item|
|
||||||
|
>
|
||||||
|
<Item.content>
|
||||||
|
<div>
|
||||||
|
<Icon @name={{destination.icon}} data-test-destination-icon={{index}} />
|
||||||
|
<span data-test-destination-name={{index}}>
|
||||||
|
{{destination.name}}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<code class="has-text-grey is-size-8" data-test-destination-type={{index}}>
|
||||||
|
{{destination.typeDisplayName}}
|
||||||
|
</code>
|
||||||
|
</Item.content>
|
||||||
|
|
||||||
|
<Item.menu>
|
||||||
|
{{#if destination.destinationPath.isLoading}}
|
||||||
|
<li class="action">
|
||||||
|
<LoadingDropdownOption />
|
||||||
|
</li>
|
||||||
|
{{else}}
|
||||||
|
<li>
|
||||||
|
<LinkTo
|
||||||
|
class="has-text-black has-text-weight-semibold"
|
||||||
|
data-test-details
|
||||||
|
@route="secrets.destinations.destination.details"
|
||||||
|
@models={{array destination.type destination.name}}
|
||||||
|
@disabled={{not destination.canRead}}
|
||||||
|
>
|
||||||
|
Details
|
||||||
|
</LinkTo>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<LinkTo
|
||||||
|
class="has-text-black has-text-weight-semibold"
|
||||||
|
data-test-edit
|
||||||
|
@route="secrets.destinations.destination.edit"
|
||||||
|
@models={{array destination.type destination.name}}
|
||||||
|
@disabled={{not destination.canEdit}}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</LinkTo>
|
||||||
|
</li>
|
||||||
|
{{#if destination.canDelete}}
|
||||||
|
<ConfirmAction
|
||||||
|
data-test-delete
|
||||||
|
@isInDropdown={{true}}
|
||||||
|
@buttonText="Delete"
|
||||||
|
@confirmMessage="The destination will be permanently deleted and all the secrets will be unsynced. This cannot be undone."
|
||||||
|
@onConfirmAction={{fn this.onDelete destination}}
|
||||||
|
/>
|
||||||
|
{{/if}}
|
||||||
|
{{/if}}
|
||||||
|
</Item.menu>
|
||||||
|
</ListItem>
|
||||||
|
{{/each}}
|
||||||
|
<Hds::Pagination::Numbered
|
||||||
|
@currentPage={{@destinations.meta.currentPage}}
|
||||||
|
@currentPageSize={{@destinations.meta.pageSize}}
|
||||||
|
@route="secrets.destinations"
|
||||||
|
@showSizeSelector={{false}}
|
||||||
|
@totalItems={{@destinations.meta.filteredTotal}}
|
||||||
|
@queryFunction={{this.paginationQueryParams}}
|
||||||
|
data-test-pagination
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<EmptyState @title={{this.noResultsMessage}} />
|
||||||
|
{{/if}}
|
||||||
94
ui/lib/sync/addon/components/secrets/page/destinations.ts
Normal file
94
ui/lib/sync/addon/components/secrets/page/destinations.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) HashiCorp, Inc.
|
||||||
|
* SPDX-License-Identifier: BUSL-1.1
|
||||||
|
*/
|
||||||
|
|
||||||
|
import Component from '@glimmer/component';
|
||||||
|
import { service } from '@ember/service';
|
||||||
|
import { action } from '@ember/object';
|
||||||
|
import { getOwner } from '@ember/application';
|
||||||
|
import errorMessage from 'vault/utils/error-message';
|
||||||
|
import { findDestination } from 'core/helpers/sync-destinations';
|
||||||
|
|
||||||
|
import type SyncDestinationModel from 'vault/vault/models/sync/destination';
|
||||||
|
import type RouterService from '@ember/routing/router-service';
|
||||||
|
import type StoreService from 'vault/services/store';
|
||||||
|
import type FlashMessageService from 'vault/services/flash-messages';
|
||||||
|
import type { EngineOwner } from 'vault/vault/app-types';
|
||||||
|
import type { SyncDestinationName, SyncDestinationType } from 'vault/vault/helpers/sync-destinations';
|
||||||
|
|
||||||
|
interface Args {
|
||||||
|
destinations: Array<SyncDestinationModel>;
|
||||||
|
nameFilter: SyncDestinationName;
|
||||||
|
typeFilter: SyncDestinationType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class SyncSecretsDestinationsPageComponent extends Component<Args> {
|
||||||
|
@service declare readonly router: RouterService;
|
||||||
|
@service declare readonly store: StoreService;
|
||||||
|
@service declare readonly flashMessages: FlashMessageService;
|
||||||
|
|
||||||
|
// typeFilter arg comes in as destination type but we need to pass the name of the type into the SearchSelect
|
||||||
|
get typeFilter() {
|
||||||
|
return findDestination(this.args.typeFilter)?.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
get destinationNames() {
|
||||||
|
return this.args.destinations.map((destination) => ({ id: destination.name, name: destination.name }));
|
||||||
|
}
|
||||||
|
|
||||||
|
get destinationTypes() {
|
||||||
|
return this.args.destinations.reduce((types: Array<{ id: string; name: string }>, destination) => {
|
||||||
|
const { typeDisplayName } = destination;
|
||||||
|
const isUnique = !types.find((type) => type.id === typeDisplayName);
|
||||||
|
if (isUnique) {
|
||||||
|
types.push({ id: typeDisplayName, name: destination.type });
|
||||||
|
}
|
||||||
|
return types;
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
get mountPoint(): string {
|
||||||
|
const owner = getOwner(this) as EngineOwner;
|
||||||
|
return owner.mountPoint;
|
||||||
|
}
|
||||||
|
|
||||||
|
get paginationQueryParams() {
|
||||||
|
return (page: number) => ({ page });
|
||||||
|
}
|
||||||
|
|
||||||
|
get noResultsMessage() {
|
||||||
|
const { nameFilter, typeFilter } = this.args;
|
||||||
|
if (nameFilter && typeFilter) {
|
||||||
|
return `There are no ${typeFilter} destinations matching "${nameFilter}".`;
|
||||||
|
}
|
||||||
|
if (nameFilter) {
|
||||||
|
return `There are no destinations matching "${nameFilter}".`;
|
||||||
|
}
|
||||||
|
if (typeFilter) {
|
||||||
|
return `There are no ${typeFilter} destinations.`;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
onFilterChange(key: string, selectObject: Array<{ id: string; name: string } | undefined>) {
|
||||||
|
this.router.transitionTo('vault.cluster.sync.secrets.destinations', {
|
||||||
|
queryParams: { [key]: selectObject[0]?.name },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
async onDelete(destination: SyncDestinationModel) {
|
||||||
|
try {
|
||||||
|
const { name } = destination;
|
||||||
|
const message = `Successfully deleted destination ${name}.`;
|
||||||
|
await destination.destroyRecord();
|
||||||
|
this.store.clearDataset('sync/destination');
|
||||||
|
this.router.transitionTo('vault.cluster.sync.secrets.destinations');
|
||||||
|
this.flashMessages.success(message);
|
||||||
|
} catch (error) {
|
||||||
|
this.flashMessages.danger(`Error deleting destination \n ${errorMessage(error)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
{{!
|
||||||
|
Copyright (c) HashiCorp, Inc.
|
||||||
|
SPDX-License-Identifier: BUSL-1.1
|
||||||
|
~}}
|
||||||
|
|
||||||
|
<SyncHeader @title={{this.header.title}} @breadcrumbs={{this.header.breadcrumbs}} />
|
||||||
|
|
||||||
|
<hr class="is-marginless has-background-gray-200" />
|
||||||
|
|
||||||
|
<form {{on "submit" (perform this.save)}} class="has-top-margin-l">
|
||||||
|
<MessageError @errorMessage={{this.error}} />
|
||||||
|
|
||||||
|
{{#each @destination.formFieldGroups as |fieldGroup|}}
|
||||||
|
{{#each-in fieldGroup as |group fields|}}
|
||||||
|
{{#if (and (eq group "Credentials") (not @destination.isNew))}}
|
||||||
|
<hr class="has-top-margin-xl has-bottom-margin-l has-background-gray-200" />
|
||||||
|
<Hds::Text::Display @tag="h2" @size="400" @weight="bold">Credentials</Hds::Text::Display>
|
||||||
|
<Hds::Text::Body @tag="p" @size="100" @color="faint" class="has-bottom-margin-m">
|
||||||
|
Connection credentials are sensitive information and the value cannot be read. Enable the input to update.
|
||||||
|
</Hds::Text::Body>
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
{{#each fields as |attr|}}
|
||||||
|
{{#if (and (eq group "Credentials") (not @destination.isNew))}}
|
||||||
|
<EnableInput data-test-enable-field={{attr.name}} class="field" @attr={{attr}}>
|
||||||
|
<FormField @attr={{attr}} @model={{@destination}} @modelValidations={{this.modelValidations}} />
|
||||||
|
</EnableInput>
|
||||||
|
{{else}}
|
||||||
|
<FormField
|
||||||
|
@attr={{attr}}
|
||||||
|
@model={{@destination}}
|
||||||
|
@modelValidations={{this.modelValidations}}
|
||||||
|
@onKeyUp={{this.updateWarningValidation}}
|
||||||
|
/>
|
||||||
|
{{/if}}
|
||||||
|
{{/each}}
|
||||||
|
{{/each-in}}
|
||||||
|
{{/each}}
|
||||||
|
|
||||||
|
<hr class="has-background-gray-200 has-top-margin-l" />
|
||||||
|
<Hds::ButtonSet>
|
||||||
|
<Hds::Button
|
||||||
|
@text={{if @model.isNew "Create destination" "Save"}}
|
||||||
|
@icon={{if this.save.isRunning "loading"}}
|
||||||
|
type="submit"
|
||||||
|
disabled={{this.save.isRunning}}
|
||||||
|
data-test-save
|
||||||
|
/>
|
||||||
|
<Hds::Button
|
||||||
|
@text="Cancel"
|
||||||
|
@color="secondary"
|
||||||
|
disabled={{this.save.isRunning}}
|
||||||
|
{{on "click" this.cancel}}
|
||||||
|
data-test-cancel
|
||||||
|
/>
|
||||||
|
{{#if this.invalidFormMessage}}
|
||||||
|
<AlertInline @type="danger" @paddingTop={{true}} @message={{this.invalidFormMessage}} @mimicRefresh={{true}} />
|
||||||
|
{{/if}}
|
||||||
|
</Hds::ButtonSet>
|
||||||
|
</form>
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) HashiCorp, Inc.
|
||||||
|
* SPDX-License-Identifier: BUSL-1.1
|
||||||
|
*/
|
||||||
|
|
||||||
|
import Component from '@glimmer/component';
|
||||||
|
import { action } from '@ember/object';
|
||||||
|
import { tracked } from '@glimmer/tracking';
|
||||||
|
import { task } from 'ember-concurrency';
|
||||||
|
import { waitFor } from '@ember/test-waiters';
|
||||||
|
import { inject as service } from '@ember/service';
|
||||||
|
import errorMessage from 'vault/utils/error-message';
|
||||||
|
|
||||||
|
import type SyncDestinationModel from 'vault/models/sync/destination';
|
||||||
|
import { ValidationMap } from 'vault/vault/app-types';
|
||||||
|
import type FlashMessageService from 'vault/services/flash-messages';
|
||||||
|
import type RouterService from '@ember/routing/router-service';
|
||||||
|
import type StoreService from 'vault/services/store';
|
||||||
|
|
||||||
|
interface Args {
|
||||||
|
destination: SyncDestinationModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class DestinationsCreateForm extends Component<Args> {
|
||||||
|
@service declare readonly flashMessages: FlashMessageService;
|
||||||
|
@service declare readonly router: RouterService;
|
||||||
|
@service declare readonly store: StoreService;
|
||||||
|
|
||||||
|
@tracked modelValidations: ValidationMap | null = null;
|
||||||
|
@tracked invalidFormMessage = '';
|
||||||
|
@tracked error = '';
|
||||||
|
|
||||||
|
get header() {
|
||||||
|
const { isNew, typeDisplayName, name } = this.args.destination;
|
||||||
|
return isNew
|
||||||
|
? {
|
||||||
|
title: `Create Destination for ${typeDisplayName}`,
|
||||||
|
breadcrumbs: [
|
||||||
|
{ label: 'Secrets Sync', route: 'secrets.overview' },
|
||||||
|
{ label: 'Select Destination', route: 'secrets.destinations.create' },
|
||||||
|
{ label: 'Create Destination' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
title: `Edit ${name}`,
|
||||||
|
breadcrumbs: [
|
||||||
|
{ label: 'Secrets Sync', route: 'secrets.overview' },
|
||||||
|
{
|
||||||
|
label: 'Destination',
|
||||||
|
route: 'secrets.destinations.destination.secrets',
|
||||||
|
model: this.args.destination,
|
||||||
|
},
|
||||||
|
{ label: 'Edit Destination' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@task
|
||||||
|
@waitFor
|
||||||
|
*save(event: Event) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
// clear out validation warnings
|
||||||
|
this.modelValidations = null;
|
||||||
|
const { destination } = this.args;
|
||||||
|
const { isValid, state, invalidFormMessage } = destination.validate();
|
||||||
|
|
||||||
|
this.modelValidations = isValid ? null : state;
|
||||||
|
this.invalidFormMessage = isValid ? '' : invalidFormMessage;
|
||||||
|
|
||||||
|
if (isValid) {
|
||||||
|
try {
|
||||||
|
const verb = destination.isNew ? 'created' : 'updated';
|
||||||
|
yield destination.save();
|
||||||
|
this.flashMessages.success(`Successfully ${verb} the destination ${destination.name}`);
|
||||||
|
this.store.clearDataset('sync/destination');
|
||||||
|
this.router.transitionTo(
|
||||||
|
'vault.cluster.sync.secrets.destinations.destination.details',
|
||||||
|
destination.type,
|
||||||
|
destination.name
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
this.error = errorMessage(error, 'Error saving destination. Please try again or contact support.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
updateWarningValidation() {
|
||||||
|
if (this.args.destination.isNew) return;
|
||||||
|
// check for warnings on change
|
||||||
|
const { state } = this.args.destination.validate();
|
||||||
|
this.modelValidations = state;
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
cancel() {
|
||||||
|
const { isNew } = this.args.destination;
|
||||||
|
const method = isNew ? 'unloadRecord' : 'rollbackAttributes';
|
||||||
|
this.args.destination[method]();
|
||||||
|
this.router.transitionTo(`vault.cluster.sync.secrets.destinations.${isNew ? 'create' : 'destination'}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{{!
|
||||||
|
Copyright (c) HashiCorp, Inc.
|
||||||
|
SPDX-License-Identifier: BUSL-1.1
|
||||||
|
~}}
|
||||||
|
|
||||||
|
<Secrets::DestinationHeader @destination={{@destination}} />
|
||||||
|
|
||||||
|
{{#each @destination.formFields as |field|}}
|
||||||
|
{{#let (get @destination field.name) as |value|}}
|
||||||
|
{{#if (includes field.name @destination.maskedParams)}}
|
||||||
|
<InfoTableRow @label={{or field.options.label (to-label field.name)}}>
|
||||||
|
<Hds::Badge @text={{this.credentialValue value}} @icon="check-circle" @color="success" />
|
||||||
|
</InfoTableRow>
|
||||||
|
{{else}}
|
||||||
|
<InfoTableRow @label={{or field.options.label (to-label field.name)}} @value={{value}} />
|
||||||
|
{{/if}}
|
||||||
|
{{/let}}
|
||||||
|
{{/each}}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) HashiCorp, Inc.
|
||||||
|
* SPDX-License-Identifier: BUSL-1.1
|
||||||
|
*/
|
||||||
|
|
||||||
|
import Component from '@glimmer/component';
|
||||||
|
|
||||||
|
import type SyncDestinationModel from 'vault/models/sync/destination';
|
||||||
|
interface Args {
|
||||||
|
destination: SyncDestinationModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class DestinationDetailsPage extends Component<Args> {
|
||||||
|
credentialValue = (value: string) => {
|
||||||
|
// if this value is empty, a destination uses globally set environment variables
|
||||||
|
return value ? 'Destination credentials added' : 'Using environment variable';
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
{{!
|
||||||
|
Copyright (c) HashiCorp, Inc.
|
||||||
|
SPDX-License-Identifier: BUSL-1.1
|
||||||
|
~}}
|
||||||
|
|
||||||
|
<Secrets::DestinationHeader @destination={{@destination}} />
|
||||||
|
|
||||||
|
{{#if @associations.meta.filteredTotal}}
|
||||||
|
<div class="has-bottom-margin-s">
|
||||||
|
{{#each @associations as |association index|}}
|
||||||
|
<ListItem as |Item|>
|
||||||
|
<Item.content>
|
||||||
|
<div>
|
||||||
|
<Hds::Badge @text="{{association.mount}}/" />
|
||||||
|
<LinkToExternal
|
||||||
|
data-test-association-name={{index}}
|
||||||
|
class="has-text-black has-text-weight-semibold"
|
||||||
|
@route="kvSecretDetails"
|
||||||
|
@models={{array association.mount association.secretName}}
|
||||||
|
>
|
||||||
|
{{association.secretName}}
|
||||||
|
</LinkToExternal>
|
||||||
|
<div>
|
||||||
|
<SyncStatusBadge @status={{association.syncStatus}} data-test-association-status={{index}} />
|
||||||
|
<code class="has-text-grey is-size-8" data-test-association-updated={{index}}>
|
||||||
|
last updated on
|
||||||
|
{{date-format association.updatedAt "MMMM do yyyy, h:mm:ss a"}}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Item.content>
|
||||||
|
|
||||||
|
<Item.menu>
|
||||||
|
{{#if (or association.setAssociationPath.isLoading association.removeAssociationPath.isLoading)}}
|
||||||
|
<li class="action">
|
||||||
|
<LoadingDropdownOption />
|
||||||
|
</li>
|
||||||
|
{{else}}
|
||||||
|
<li class="is-inline-block">
|
||||||
|
<Hds::Button
|
||||||
|
@text="Sync now"
|
||||||
|
class="link"
|
||||||
|
disabled={{not association.canSync}}
|
||||||
|
data-test-association-action="sync"
|
||||||
|
{{on "click" (fn this.update association "set")}}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<LinkToExternal
|
||||||
|
class="has-text-black has-text-weight-semibold"
|
||||||
|
data-test-association-action="view"
|
||||||
|
@route="kvSecretDetails"
|
||||||
|
@models={{array association.mount association.secretName}}
|
||||||
|
>
|
||||||
|
View secret
|
||||||
|
</LinkToExternal>
|
||||||
|
</li>
|
||||||
|
{{#if association.canUnsync}}
|
||||||
|
<ConfirmAction
|
||||||
|
data-test-association-action="unsync"
|
||||||
|
@isInDropdown={{true}}
|
||||||
|
@buttonText="Unsync"
|
||||||
|
@confirmMessage="This secret will be unsynced from all destinations."
|
||||||
|
@onConfirmAction={{fn this.update association "remove"}}
|
||||||
|
/>
|
||||||
|
{{/if}}
|
||||||
|
{{/if}}
|
||||||
|
</Item.menu>
|
||||||
|
</ListItem>
|
||||||
|
{{/each}}
|
||||||
|
<Hds::Pagination::Numbered
|
||||||
|
@currentPage={{@associations.meta.currentPage}}
|
||||||
|
@currentPageSize={{@associations.meta.pageSize}}
|
||||||
|
@route="secrets.destinations.destination.secrets"
|
||||||
|
@showSizeSelector={{false}}
|
||||||
|
@totalItems={{@associations.meta.filteredTotal}}
|
||||||
|
@queryFunction={{this.paginationQueryParams}}
|
||||||
|
data-test-pagination
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<EmptyState
|
||||||
|
@title="No synced secrets yet"
|
||||||
|
@message="Select secrets from existing KV version 2 engines and sync them to the destination."
|
||||||
|
>
|
||||||
|
<LinkTo class="has-top-margin-xs" @route="secrets.destinations.destination.sync">
|
||||||
|
Sync secrets
|
||||||
|
</LinkTo>
|
||||||
|
</EmptyState>
|
||||||
|
{{/if}}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) HashiCorp, Inc.
|
||||||
|
* SPDX-License-Identifier: BUSL-1.1
|
||||||
|
*/
|
||||||
|
|
||||||
|
import Component from '@glimmer/component';
|
||||||
|
import { service } from '@ember/service';
|
||||||
|
import { action } from '@ember/object';
|
||||||
|
import { getOwner } from '@ember/application';
|
||||||
|
import errorMessage from 'vault/utils/error-message';
|
||||||
|
|
||||||
|
import SyncDestinationModel from 'vault/vault/models/sync/destination';
|
||||||
|
import type SyncAssociationModel from 'vault/vault/models/sync/association';
|
||||||
|
import type RouterService from '@ember/routing/router-service';
|
||||||
|
import type StoreService from 'vault/services/store';
|
||||||
|
import type FlashMessageService from 'vault/services/flash-messages';
|
||||||
|
import type { EngineOwner } from 'vault/vault/app-types';
|
||||||
|
|
||||||
|
interface Args {
|
||||||
|
destination: SyncDestinationModel;
|
||||||
|
associations: Array<SyncAssociationModel>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class SyncSecretsDestinationsPageComponent extends Component<Args> {
|
||||||
|
@service declare readonly router: RouterService;
|
||||||
|
@service declare readonly store: StoreService;
|
||||||
|
@service declare readonly flashMessages: FlashMessageService;
|
||||||
|
|
||||||
|
get mountPoint(): string {
|
||||||
|
const owner = getOwner(this) as EngineOwner;
|
||||||
|
return owner.mountPoint;
|
||||||
|
}
|
||||||
|
|
||||||
|
get paginationQueryParams() {
|
||||||
|
return (page: number) => ({ page });
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
async update(association: SyncAssociationModel, operation: string) {
|
||||||
|
try {
|
||||||
|
await association.save({ adapterOptions: { action: operation } });
|
||||||
|
// this message can be expanded after testing -- deliberately generic for now
|
||||||
|
this.flashMessages.success(
|
||||||
|
'Sync operation successfully initiated. Status will be updated on secret when complete.'
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
this.flashMessages.danger(`Sync operation error: \n ${errorMessage(error)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
{{!
|
||||||
|
Copyright (c) HashiCorp, Inc.
|
||||||
|
SPDX-License-Identifier: BUSL-1.1
|
||||||
|
~}}
|
||||||
|
|
||||||
|
<SyncHeader
|
||||||
|
@title="Sync Secrets to {{@destination.typeDisplayName}}"
|
||||||
|
@breadcrumbs={{array
|
||||||
|
(hash label="Secrets Sync" route="secrets.overview")
|
||||||
|
(hash label="Destinations" route="secrets.destinations")
|
||||||
|
(hash label="Destination" route="secrets.destinations.destination")
|
||||||
|
(hash label="Sync")
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<form {{on "submit" (perform this.setAssociation)}} class={{unless (or this.error this.syncedSecret) "has-top-margin-m"}}>
|
||||||
|
<MessageError @errorMessage={{this.error}} />
|
||||||
|
|
||||||
|
{{#if this.syncedSecret}}
|
||||||
|
<Hds::Alert @type="inline" @color="success" as |A|>
|
||||||
|
<A.Title>Successfully synced a secret</A.Title>
|
||||||
|
<A.Description data-test-sync-success-message>
|
||||||
|
Sync operation successfully initiated for "{{this.syncedSecret}}". You can continue on this page to sync more
|
||||||
|
secrets.
|
||||||
|
</A.Description>
|
||||||
|
</Hds::Alert>
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
<div class="has-top-margin-l">
|
||||||
|
|
||||||
|
<p class="is-label">Which secrets would you like us to sync?</p>
|
||||||
|
<p class="sub-text">
|
||||||
|
Select a KV engine mount and path to sync a secret to the destination.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="has-top-margin-l">
|
||||||
|
{{#if this.mounts}}
|
||||||
|
<SearchSelect
|
||||||
|
@label="Select a mount for the KV engine"
|
||||||
|
@options={{this.mounts}}
|
||||||
|
@selectLimit={{1}}
|
||||||
|
@disallowNewItems={{true}}
|
||||||
|
@onChange={{this.setMount}}
|
||||||
|
data-test-sync-mount-select
|
||||||
|
/>
|
||||||
|
{{else}}
|
||||||
|
<FormFieldLabel for="kv-mount-input" @label="Enter an existing KV engine mount" />
|
||||||
|
<FilterInput
|
||||||
|
id="kv-mount-input"
|
||||||
|
placeholder="KV engine mount path"
|
||||||
|
value={{this.mountPath}}
|
||||||
|
@onInput={{fn (mut this.mountPath)}}
|
||||||
|
data-test-sync-mount-input
|
||||||
|
/>
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
<KvSuggestionInput
|
||||||
|
@label="Select a secret to sync"
|
||||||
|
@subText="Enter the full path to the secret. Suggestions will display below if permitted by policy."
|
||||||
|
@value={{this.secretPath}}
|
||||||
|
@mountPath={{this.mountPath}}
|
||||||
|
@onChange={{fn (mut this.secretPath)}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="field box is-fullwidth is-bottomless has-top-margin-xxxl">
|
||||||
|
<Hds::ButtonSet>
|
||||||
|
<Hds::Button
|
||||||
|
@text="Sync to destination"
|
||||||
|
@color="primary"
|
||||||
|
@icon={{if this.setAssociation.isRunning "loading"}}
|
||||||
|
type="submit"
|
||||||
|
disabled={{this.isSubmitDisabled}}
|
||||||
|
data-test-sync-submit
|
||||||
|
/>
|
||||||
|
<Hds::Button @text="Back" @color="secondary" {{on "click" this.back}} data-test-sync-cancel />
|
||||||
|
</Hds::ButtonSet>
|
||||||
|
{{#if this.isSecretDirectory}}
|
||||||
|
<AlertInline
|
||||||
|
@type="warning"
|
||||||
|
@message="Syncing secret directories is not available at this time, please type a path to a single secret"
|
||||||
|
class="has-top-margin-s"
|
||||||
|
/>
|
||||||
|
{{/if}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) HashiCorp, Inc.
|
||||||
|
* SPDX-License-Identifier: BUSL-1.1
|
||||||
|
*/
|
||||||
|
|
||||||
|
import Component from '@glimmer/component';
|
||||||
|
import { tracked } from '@glimmer/tracking';
|
||||||
|
import { service } from '@ember/service';
|
||||||
|
import { action } from '@ember/object';
|
||||||
|
import { task } from 'ember-concurrency';
|
||||||
|
import { keyIsFolder } from 'core/utils/key-utils';
|
||||||
|
import errorMessage from 'vault/utils/error-message';
|
||||||
|
|
||||||
|
import type SyncDestinationModel from 'vault/models/sync/destination';
|
||||||
|
import type RouterService from '@ember/routing/router-service';
|
||||||
|
import type StoreService from 'vault/services/store';
|
||||||
|
import type FlashMessageService from 'vault/services/flash-messages';
|
||||||
|
import type { SearchSelectOption } from 'vault/vault/app-types';
|
||||||
|
|
||||||
|
interface Args {
|
||||||
|
destination: SyncDestinationModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class DestinationSyncPageComponent extends Component<Args> {
|
||||||
|
@service declare readonly router: RouterService;
|
||||||
|
@service declare readonly store: StoreService;
|
||||||
|
@service declare readonly flashMessages: FlashMessageService;
|
||||||
|
|
||||||
|
constructor(owner: unknown, args: Args) {
|
||||||
|
super(owner, args);
|
||||||
|
this.fetchMounts();
|
||||||
|
}
|
||||||
|
|
||||||
|
@tracked mounts: SearchSelectOption[] = [];
|
||||||
|
@tracked mountPath = '';
|
||||||
|
@tracked secretPath = '';
|
||||||
|
@tracked error = '';
|
||||||
|
@tracked syncedSecret = '';
|
||||||
|
|
||||||
|
get isSecretDirectory() {
|
||||||
|
return this.secretPath && keyIsFolder(this.secretPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
get isSubmitDisabled() {
|
||||||
|
return !this.mountPath || !this.secretPath || this.isSecretDirectory || this.setAssociation.isRunning;
|
||||||
|
}
|
||||||
|
|
||||||
|
willDestroy(): void {
|
||||||
|
this.store.clearDataset('sync/association');
|
||||||
|
super.willDestroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
// unable to use built-in fetch functionality of SearchSelect since we need to filter by kv type
|
||||||
|
async fetchMounts() {
|
||||||
|
try {
|
||||||
|
const secretEngines = await this.store.query('secret-engine', {});
|
||||||
|
this.mounts = secretEngines.reduce((filtered, model) => {
|
||||||
|
if (model.type === 'kv' && model.version === 2) {
|
||||||
|
filtered.push({ name: model.path, id: model.path });
|
||||||
|
}
|
||||||
|
return filtered;
|
||||||
|
}, []);
|
||||||
|
} catch (error) {
|
||||||
|
// the user is still able to manually enter the mount path
|
||||||
|
// InputSearch component will render in this case
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
back() {
|
||||||
|
this.router.transitionTo('vault.cluster.sync.secrets.destinations.destination.secrets');
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
setMount(selected: Array<string>) {
|
||||||
|
this.mountPath = selected[0] || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
setAssociation = task({}, async (event: Event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
try {
|
||||||
|
this.syncedSecret = '';
|
||||||
|
const { name: destinationName, type: destinationType } = this.args.destination;
|
||||||
|
const mount = keyIsFolder(this.mountPath) ? this.mountPath.slice(0, -1) : this.mountPath; // strip trailing slash from mount path
|
||||||
|
const association = this.store.createRecord('sync/association', {
|
||||||
|
destinationName,
|
||||||
|
destinationType,
|
||||||
|
mount,
|
||||||
|
secretName: this.secretPath,
|
||||||
|
});
|
||||||
|
await association.save({ adapterOptions: { action: 'set' } });
|
||||||
|
this.syncedSecret = this.secretPath;
|
||||||
|
} catch (error) {
|
||||||
|
this.error = `Sync operation error: \n ${errorMessage(error)}`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
{{!
|
||||||
|
Copyright (c) HashiCorp, Inc.
|
||||||
|
SPDX-License-Identifier: BUSL-1.1
|
||||||
|
~}}
|
||||||
|
|
||||||
|
<SyncHeader
|
||||||
|
@title="Select a Destination"
|
||||||
|
@breadcrumbs={{array (hash label="Secrets Sync" route="secrets.overview") (hash label="Select Destination")}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{{#each (array "cloud" "dev-tools") as |category|}}
|
||||||
|
<Hds::Text::Display @tag="h2" @size="300" class="has-top-padding-l has-bottom-padding-l">
|
||||||
|
{{if (eq category "cloud") "Cloud service providers" "Developer tools"}}
|
||||||
|
</Hds::Text::Display>
|
||||||
|
|
||||||
|
<div class="flex row-wrap row-gap-8 column-gap-16 has-bottom-padding-m">
|
||||||
|
{{#each (filter-by "category" category (sync-destinations)) as |d|}}
|
||||||
|
<SelectableCard
|
||||||
|
class="has-padding-l flex card-width-20"
|
||||||
|
@onClick={{transition-to "vault.cluster.sync.secrets.destinations.create.destination" d.type}}
|
||||||
|
data-test-select-destination={{d.type}}
|
||||||
|
>
|
||||||
|
<Icon @name={{d.icon}} @size="24" />
|
||||||
|
<Hds::Text::Display @tag="h3" @size="300">
|
||||||
|
{{d.name}}
|
||||||
|
</Hds::Text::Display>
|
||||||
|
</SelectableCard>
|
||||||
|
{{/each}}
|
||||||
|
</div>
|
||||||
|
{{/each}}
|
||||||
|
|
||||||
|
<hr class="has-background-gray-100" />
|
||||||
|
<Hds::Button @text="Cancel" @color="secondary" @route="secrets.overview" />
|
||||||
157
ui/lib/sync/addon/components/secrets/page/overview.hbs
Normal file
157
ui/lib/sync/addon/components/secrets/page/overview.hbs
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
{{!
|
||||||
|
Copyright (c) HashiCorp, Inc.
|
||||||
|
SPDX-License-Identifier: BUSL-1.1
|
||||||
|
~}}
|
||||||
|
|
||||||
|
<SyncHeader @title="Secrets Sync">
|
||||||
|
<:actions>
|
||||||
|
{{#unless @destinations}}
|
||||||
|
<Hds::Button @text="Create first destination" @route="secrets.destinations.create" data-test-cta-button />
|
||||||
|
{{/unless}}
|
||||||
|
</:actions>
|
||||||
|
</SyncHeader>
|
||||||
|
|
||||||
|
{{#if @destinations}}
|
||||||
|
<div class="tabs-container box is-bottomless is-marginless is-paddingless">
|
||||||
|
<nav class="tabs" aria-label="destination tabs">
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<LinkTo @route="secrets.overview" data-test-tab="Overview">Overview</LinkTo>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<LinkTo @route="secrets.destinations" data-test-tab="Destinations">Destinations</LinkTo>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
<Toolbar aria-label="toolbar for sync overview page">
|
||||||
|
<ToolbarActions aria-label="toolbar action links">
|
||||||
|
<ToolbarLink
|
||||||
|
aria-label="toolbar link navigates to create a new destination"
|
||||||
|
@route="secrets.destinations.create"
|
||||||
|
@type="add"
|
||||||
|
data-test-create-destination
|
||||||
|
>
|
||||||
|
Create new destination
|
||||||
|
</ToolbarLink>
|
||||||
|
</ToolbarActions>
|
||||||
|
</Toolbar>
|
||||||
|
|
||||||
|
<OverviewCard @cardTitle="Secrets by destination" class="has-top-margin-l">
|
||||||
|
{{#if this.fetchAssociationsForDestinations.isRunning}}
|
||||||
|
<div data-test-sync-overview-loading>
|
||||||
|
<Icon @name="loading" @size="24" />
|
||||||
|
Loading destinations...
|
||||||
|
</div>
|
||||||
|
{{else if (not this.destinationMetrics)}}
|
||||||
|
<EmptyState
|
||||||
|
@title="Error fetching information"
|
||||||
|
@message="Ensure that the policy has access to read sync associations."
|
||||||
|
>
|
||||||
|
<DocLink @path="/vault/api-docs/system/secrets-sync#read-associations">
|
||||||
|
API reference
|
||||||
|
</DocLink>
|
||||||
|
</EmptyState>
|
||||||
|
{{else}}
|
||||||
|
<Hds::Table>
|
||||||
|
<:head as |H|>
|
||||||
|
<H.Tr>
|
||||||
|
<H.Th>Sync destination</H.Th>
|
||||||
|
<H.Th @align="right"># of secrets</H.Th>
|
||||||
|
<H.Th @align="right">Last updated</H.Th>
|
||||||
|
<H.Th @align="right">Actions</H.Th>
|
||||||
|
</H.Tr>
|
||||||
|
</:head>
|
||||||
|
<:body as |B|>
|
||||||
|
{{#each this.destinationMetrics as |data index|}}
|
||||||
|
<B.Tr data-test-overview-table-row>
|
||||||
|
<B.Td>
|
||||||
|
<Icon @name={{data.icon}} data-test-overview-table-icon={{index}} />
|
||||||
|
<span data-test-overview-table-name={{index}}>{{data.name}}</span>
|
||||||
|
{{#if data.status}}
|
||||||
|
<Hds::Badge
|
||||||
|
@text={{data.status}}
|
||||||
|
@color={{if (eq data.status "All synced") "success"}}
|
||||||
|
data-test-overview-table-badge={{index}}
|
||||||
|
/>
|
||||||
|
{{/if}}
|
||||||
|
</B.Td>
|
||||||
|
<B.Td @align="right" data-test-overview-table-total={{index}}>
|
||||||
|
{{data.associationCount}}
|
||||||
|
</B.Td>
|
||||||
|
<B.Td @align="right" data-test-overview-table-updated={{index}}>
|
||||||
|
{{#if data.lastUpdated}}
|
||||||
|
{{date-format data.lastUpdated "MMMM do yyyy, h:mm:ss a"}}
|
||||||
|
{{else}}
|
||||||
|
—
|
||||||
|
{{/if}}
|
||||||
|
</B.Td>
|
||||||
|
<B.Td @align="right">
|
||||||
|
<Hds::Dropdown @isInline={{true}} as |dd|>
|
||||||
|
<dd.ToggleIcon
|
||||||
|
@icon="more-horizontal"
|
||||||
|
@text="Actions"
|
||||||
|
@hasChevron={{false}}
|
||||||
|
@size="small"
|
||||||
|
data-test-overview-table-action-toggle={{index}}
|
||||||
|
/>
|
||||||
|
<dd.Interactive
|
||||||
|
@route="secrets.destinations.destination.sync"
|
||||||
|
@model={{data}}
|
||||||
|
@text="Sync secrets"
|
||||||
|
data-test-overview-table-action="sync"
|
||||||
|
/>
|
||||||
|
<dd.Interactive
|
||||||
|
@route="secrets.destinations.destination.secrets"
|
||||||
|
@models={{array data.type data.name}}
|
||||||
|
@text="Details"
|
||||||
|
data-test-overview-table-action="details"
|
||||||
|
/>
|
||||||
|
</Hds::Dropdown>
|
||||||
|
</B.Td>
|
||||||
|
</B.Tr>
|
||||||
|
{{/each}}
|
||||||
|
</:body>
|
||||||
|
</Hds::Table>
|
||||||
|
|
||||||
|
<Hds::Pagination::Numbered
|
||||||
|
@totalItems={{@destinations.length}}
|
||||||
|
@currentPage={{this.page}}
|
||||||
|
@currentPageSize={{this.pageSize}}
|
||||||
|
@showSizeSelector={{false}}
|
||||||
|
@onPageChange={{perform this.fetchAssociationsForDestinations}}
|
||||||
|
/>
|
||||||
|
{{/if}}
|
||||||
|
</OverviewCard>
|
||||||
|
|
||||||
|
<div class="is-grid grid-2-columns grid-gap-2 has-top-margin-l has-bottom-margin-l">
|
||||||
|
<OverviewCard
|
||||||
|
@cardTitle="Total destinations"
|
||||||
|
@subText="The total number of connected destinations"
|
||||||
|
@actionText="Create new"
|
||||||
|
@actionTo="secrets.destinations.create"
|
||||||
|
class="is-flex-half"
|
||||||
|
>
|
||||||
|
<h2 class="title is-2 has-font-weight-normal has-top-margin-m" data-test-overview-card-content="Total destinations">
|
||||||
|
{{or @destinations.length "None"}}
|
||||||
|
</h2>
|
||||||
|
</OverviewCard>
|
||||||
|
<OverviewCard
|
||||||
|
@cardTitle="Total sync associations"
|
||||||
|
@subText="Total sync associations that count towards client count"
|
||||||
|
@actionText="View billing"
|
||||||
|
@actionTo="clientCountDashboard"
|
||||||
|
@actionExternal={{true}}
|
||||||
|
class="is-flex-half"
|
||||||
|
>
|
||||||
|
<h2
|
||||||
|
class="title is-2 has-font-weight-normal has-top-margin-m"
|
||||||
|
data-test-overview-card-content="Total sync associations"
|
||||||
|
>
|
||||||
|
{{or @totalAssociations "None"}}
|
||||||
|
</h2>
|
||||||
|
</OverviewCard>
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<Secrets::LandingCta />
|
||||||
|
{{/if}}
|
||||||
52
ui/lib/sync/addon/components/secrets/page/overview.ts
Normal file
52
ui/lib/sync/addon/components/secrets/page/overview.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) HashiCorp, Inc.
|
||||||
|
* SPDX-License-Identifier: BUSL-1.1
|
||||||
|
*/
|
||||||
|
|
||||||
|
import Component from '@glimmer/component';
|
||||||
|
import { tracked } from '@glimmer/tracking';
|
||||||
|
import { service } from '@ember/service';
|
||||||
|
import { task } from 'ember-concurrency';
|
||||||
|
import Ember from 'ember';
|
||||||
|
|
||||||
|
import type RouterService from '@ember/routing/router-service';
|
||||||
|
import type StoreService from 'vault/services/store';
|
||||||
|
import type FlashMessageService from 'vault/services/flash-messages';
|
||||||
|
import type { SyncDestinationAssociationMetrics } from 'vault/vault/adapters/sync/association';
|
||||||
|
import type SyncDestinationModel from 'vault/vault/models/sync/destination';
|
||||||
|
|
||||||
|
interface Args {
|
||||||
|
destinations: Array<SyncDestinationModel>;
|
||||||
|
totalAssociations: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class SyncSecretsDestinationsPageComponent extends Component<Args> {
|
||||||
|
@service declare readonly router: RouterService;
|
||||||
|
@service declare readonly store: StoreService;
|
||||||
|
@service declare readonly flashMessages: FlashMessageService;
|
||||||
|
|
||||||
|
@tracked destinationMetrics: SyncDestinationAssociationMetrics[] = [];
|
||||||
|
@tracked page = 1;
|
||||||
|
|
||||||
|
pageSize = Ember.testing ? 3 : 5; // lower in tests to test pagination without seeding more data
|
||||||
|
|
||||||
|
constructor(owner: unknown, args: Args) {
|
||||||
|
super(owner, args);
|
||||||
|
if (this.args.destinations.length) {
|
||||||
|
this.fetchAssociationsForDestinations.perform();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchAssociationsForDestinations = task(this, {}, async (page = 1) => {
|
||||||
|
try {
|
||||||
|
const total = page * this.pageSize;
|
||||||
|
const paginatedDestinations = this.args.destinations.slice(total - this.pageSize, total);
|
||||||
|
this.destinationMetrics = await this.store
|
||||||
|
.adapterFor('sync/association')
|
||||||
|
.fetchByDestinations(paginatedDestinations);
|
||||||
|
this.page = page;
|
||||||
|
} catch (error) {
|
||||||
|
this.destinationMetrics = [];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
26
ui/lib/sync/addon/components/sync-header.hbs
Normal file
26
ui/lib/sync/addon/components/sync-header.hbs
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{{!
|
||||||
|
Copyright (c) HashiCorp, Inc.
|
||||||
|
SPDX-License-Identifier: BUSL-1.1
|
||||||
|
~}}
|
||||||
|
|
||||||
|
<PageHeader as |p|>
|
||||||
|
<p.top>
|
||||||
|
<Page::Breadcrumbs @breadcrumbs={{or @breadcrumbs (array (hash label="Secrets Sync"))}} />
|
||||||
|
</p.top>
|
||||||
|
|
||||||
|
<p.levelLeft>
|
||||||
|
<h1 class="title is-3 has-bottom-margin-m" data-test-page-title>
|
||||||
|
{{#if @icon}}
|
||||||
|
<Icon @name={{@icon}} @size="24" />
|
||||||
|
{{/if}}
|
||||||
|
{{@title}}
|
||||||
|
{{#if this.version.isCommunity}}
|
||||||
|
<Hds::Badge @text="Enterprise feature" @color="highlight" @size="large" />
|
||||||
|
{{/if}}
|
||||||
|
</h1>
|
||||||
|
</p.levelLeft>
|
||||||
|
|
||||||
|
<p.levelRight>
|
||||||
|
{{yield to="actions"}}
|
||||||
|
</p.levelRight>
|
||||||
|
</PageHeader>
|
||||||
20
ui/lib/sync/addon/components/sync-header.ts
Normal file
20
ui/lib/sync/addon/components/sync-header.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) HashiCorp, Inc.
|
||||||
|
* SPDX-License-Identifier: BUSL-1.1
|
||||||
|
*/
|
||||||
|
|
||||||
|
import Component from '@glimmer/component';
|
||||||
|
import { inject as service } from '@ember/service';
|
||||||
|
|
||||||
|
import type VersionService from 'vault/services/version';
|
||||||
|
import type { Breadcrumb } from 'vault/vault/app-types';
|
||||||
|
|
||||||
|
interface Args {
|
||||||
|
title: string;
|
||||||
|
icon?: string;
|
||||||
|
breadcrumbs?: Breadcrumb[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class SyncHeaderComponent extends Component<Args> {
|
||||||
|
@service declare readonly version: VersionService;
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) HashiCorp, Inc.
|
||||||
|
* SPDX-License-Identifier: BUSL-1.1
|
||||||
|
*/
|
||||||
|
|
||||||
|
import Controller from '@ember/controller';
|
||||||
|
|
||||||
|
export default class SyncDestinationSecretsController extends Controller {
|
||||||
|
queryParams = ['page'];
|
||||||
|
}
|
||||||
10
ui/lib/sync/addon/controllers/secrets/destinations/index.ts
Normal file
10
ui/lib/sync/addon/controllers/secrets/destinations/index.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) HashiCorp, Inc.
|
||||||
|
* SPDX-License-Identifier: BUSL-1.1
|
||||||
|
*/
|
||||||
|
|
||||||
|
import Controller from '@ember/controller';
|
||||||
|
|
||||||
|
export default class SyncSecretsDestinationsIndexController extends Controller {
|
||||||
|
queryParams = ['name', 'type', 'page'];
|
||||||
|
}
|
||||||
22
ui/lib/sync/addon/engine.js
Normal file
22
ui/lib/sync/addon/engine.js
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) HashiCorp, Inc.
|
||||||
|
* SPDX-License-Identifier: BUSL-1.1
|
||||||
|
*/
|
||||||
|
|
||||||
|
import Engine from 'ember-engines/engine';
|
||||||
|
import loadInitializers from 'ember-load-initializers';
|
||||||
|
import Resolver from 'ember-resolver';
|
||||||
|
import config from './config/environment';
|
||||||
|
|
||||||
|
const { modulePrefix } = config;
|
||||||
|
|
||||||
|
export default class SyncEngine extends Engine {
|
||||||
|
modulePrefix = modulePrefix;
|
||||||
|
Resolver = Resolver;
|
||||||
|
dependencies = {
|
||||||
|
services: ['flash-messages', 'router', 'store', 'version'],
|
||||||
|
externalRoutes: ['kvSecretDetails', 'clientCountDashboard'],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
loadInitializers(SyncEngine, modulePrefix);
|
||||||
23
ui/lib/sync/addon/routes.js
Normal file
23
ui/lib/sync/addon/routes.js
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) HashiCorp, Inc.
|
||||||
|
* SPDX-License-Identifier: BUSL-1.1
|
||||||
|
*/
|
||||||
|
|
||||||
|
import buildRoutes from 'ember-engines/routes';
|
||||||
|
|
||||||
|
export default buildRoutes(function () {
|
||||||
|
this.route('secrets', function () {
|
||||||
|
this.route('overview');
|
||||||
|
this.route('destinations', function () {
|
||||||
|
this.route('create', function () {
|
||||||
|
this.route('destination', { path: '/:type' });
|
||||||
|
});
|
||||||
|
this.route('destination', { path: '/:type/:name' }, function () {
|
||||||
|
this.route('edit');
|
||||||
|
this.route('details');
|
||||||
|
this.route('secrets');
|
||||||
|
this.route('sync');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
17
ui/lib/sync/addon/routes/index.ts
Normal file
17
ui/lib/sync/addon/routes/index.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) HashiCorp, Inc.
|
||||||
|
* SPDX-License-Identifier: BUSL-1.1
|
||||||
|
*/
|
||||||
|
|
||||||
|
import Route from '@ember/routing/route';
|
||||||
|
import { inject as service } from '@ember/service';
|
||||||
|
|
||||||
|
import type RouterService from '@ember/routing/router-service';
|
||||||
|
|
||||||
|
export default class SyncIndexRoute extends Route {
|
||||||
|
@service declare readonly router: RouterService;
|
||||||
|
|
||||||
|
redirect() {
|
||||||
|
this.router.transitionTo('vault.cluster.sync.secrets.overview');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) HashiCorp, Inc.
|
||||||
|
* SPDX-License-Identifier: BUSL-1.1
|
||||||
|
*/
|
||||||
|
|
||||||
|
import Route from '@ember/routing/route';
|
||||||
|
import { inject as service } from '@ember/service';
|
||||||
|
|
||||||
|
import type StoreService from 'vault/services/store';
|
||||||
|
|
||||||
|
interface Params {
|
||||||
|
type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class SyncSecretsDestinationsCreateDestinationRoute extends Route {
|
||||||
|
@service declare readonly store: StoreService;
|
||||||
|
|
||||||
|
model(params: Params) {
|
||||||
|
const { type } = params;
|
||||||
|
return this.store.createRecord(`sync/destinations/${type}`, { type });
|
||||||
|
}
|
||||||
|
}
|
||||||
23
ui/lib/sync/addon/routes/secrets/destinations/destination.ts
Normal file
23
ui/lib/sync/addon/routes/secrets/destinations/destination.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) HashiCorp, Inc.
|
||||||
|
* SPDX-License-Identifier: BUSL-1.1
|
||||||
|
*/
|
||||||
|
|
||||||
|
import Route from '@ember/routing/route';
|
||||||
|
import { inject as service } from '@ember/service';
|
||||||
|
|
||||||
|
import type Store from '@ember-data/store';
|
||||||
|
|
||||||
|
interface RouteParams {
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class SyncSecretsDestinationsDestinationRoute extends Route {
|
||||||
|
@service declare readonly store: Store;
|
||||||
|
|
||||||
|
model(params: RouteParams) {
|
||||||
|
const { name, type } = params;
|
||||||
|
return this.store.findRecord(`sync/destinations/${type}`, name);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) HashiCorp, Inc.
|
||||||
|
* SPDX-License-Identifier: BUSL-1.1
|
||||||
|
*/
|
||||||
|
|
||||||
|
import Route from '@ember/routing/route';
|
||||||
|
import { inject as service } from '@ember/service';
|
||||||
|
|
||||||
|
import type RouterService from '@ember/routing/router-service';
|
||||||
|
|
||||||
|
export default class SyncSecretsDestinationsDestinationIndexRoute extends Route {
|
||||||
|
@service declare readonly router: RouterService;
|
||||||
|
|
||||||
|
redirect() {
|
||||||
|
this.router.transitionTo('vault.cluster.sync.secrets.destinations.destination.details');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) HashiCorp, Inc.
|
||||||
|
* SPDX-License-Identifier: BUSL-1.1
|
||||||
|
*/
|
||||||
|
|
||||||
|
import Route from '@ember/routing/route';
|
||||||
|
import { inject as service } from '@ember/service';
|
||||||
|
import { hash } from 'rsvp';
|
||||||
|
|
||||||
|
import type StoreService from 'vault/services/store';
|
||||||
|
import SyncDestinationModel from 'vault/vault/models/sync/destination';
|
||||||
|
|
||||||
|
interface SyncDestinationSecretsRouteParams {
|
||||||
|
page: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class SyncDestinationSecretsRoute extends Route {
|
||||||
|
@service declare readonly store: StoreService;
|
||||||
|
|
||||||
|
model(params: SyncDestinationSecretsRouteParams) {
|
||||||
|
const destination = this.modelFor('secrets.destinations.destination') as SyncDestinationModel;
|
||||||
|
return hash({
|
||||||
|
destination,
|
||||||
|
associations: this.store.lazyPaginatedQuery('sync/association', {
|
||||||
|
responsePath: 'data.keys',
|
||||||
|
page: Number(params.page) || 1,
|
||||||
|
destinationType: destination.type,
|
||||||
|
destinationName: destination.name,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
50
ui/lib/sync/addon/routes/secrets/destinations/index.ts
Normal file
50
ui/lib/sync/addon/routes/secrets/destinations/index.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) HashiCorp, Inc.
|
||||||
|
* SPDX-License-Identifier: BUSL-1.1
|
||||||
|
*/
|
||||||
|
|
||||||
|
import Route from '@ember/routing/route';
|
||||||
|
import { inject as service } from '@ember/service';
|
||||||
|
import { hash } from 'rsvp';
|
||||||
|
|
||||||
|
import type StoreService from 'vault/services/store';
|
||||||
|
import type SyncDestinationModel from 'vault/vault/models/sync/destination';
|
||||||
|
|
||||||
|
interface SyncSecretsDestinationsIndexRouteParams {
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
page: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class SyncSecretsDestinationsIndexRoute extends Route {
|
||||||
|
@service declare readonly store: StoreService;
|
||||||
|
|
||||||
|
filterData(dataset: Array<SyncDestinationModel>, name: string, type: string): Array<SyncDestinationModel> {
|
||||||
|
let filteredDataset = dataset;
|
||||||
|
const filter = (key: keyof SyncDestinationModel, value: string) => {
|
||||||
|
return dataset.filter((model) => {
|
||||||
|
return model[key].toLowerCase().includes(value.toLowerCase());
|
||||||
|
});
|
||||||
|
};
|
||||||
|
if (type) {
|
||||||
|
filteredDataset = filter('type', type);
|
||||||
|
}
|
||||||
|
if (name) {
|
||||||
|
filteredDataset = filter('name', name);
|
||||||
|
}
|
||||||
|
return filteredDataset;
|
||||||
|
}
|
||||||
|
|
||||||
|
async model(params: SyncSecretsDestinationsIndexRouteParams) {
|
||||||
|
const { name, type, page } = params;
|
||||||
|
return hash({
|
||||||
|
destinations: this.store.lazyPaginatedQuery('sync/destination', {
|
||||||
|
page: Number(page) || 1,
|
||||||
|
pageFilter: (dataset: Array<SyncDestinationModel>) => this.filterData(dataset, name, type),
|
||||||
|
responsePath: 'data.keys',
|
||||||
|
}),
|
||||||
|
nameFilter: params.name,
|
||||||
|
typeFilter: params.type,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
24
ui/lib/sync/addon/routes/secrets/overview.ts
Normal file
24
ui/lib/sync/addon/routes/secrets/overview.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) HashiCorp, Inc.
|
||||||
|
* SPDX-License-Identifier: BUSL-1.1
|
||||||
|
*/
|
||||||
|
|
||||||
|
import Route from '@ember/routing/route';
|
||||||
|
import { inject as service } from '@ember/service';
|
||||||
|
import { hash } from 'rsvp';
|
||||||
|
|
||||||
|
import type StoreService from 'vault/services/store';
|
||||||
|
|
||||||
|
export default class SyncSecretsOverviewRoute extends Route {
|
||||||
|
@service declare readonly store: StoreService;
|
||||||
|
|
||||||
|
async model() {
|
||||||
|
return hash({
|
||||||
|
destinations: this.store.query('sync/destination', {}).catch(() => []),
|
||||||
|
associations: this.store
|
||||||
|
.adapterFor('sync/association')
|
||||||
|
.queryAll()
|
||||||
|
.catch(() => []),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
8
ui/lib/sync/addon/templates/error.hbs
Normal file
8
ui/lib/sync/addon/templates/error.hbs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{{!
|
||||||
|
Copyright (c) HashiCorp, Inc.
|
||||||
|
SPDX-License-Identifier: BUSL-1.1
|
||||||
|
~}}
|
||||||
|
|
||||||
|
<SyncHeader @title="Secrets Sync" @breadcrumbs={{array (hash label="Secrets Sync" route="secrets.overview")}} />
|
||||||
|
|
||||||
|
<Page::Error @error={{this.model}} />
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{{!
|
||||||
|
Copyright (c) HashiCorp, Inc.
|
||||||
|
SPDX-License-Identifier: BUSL-1.1
|
||||||
|
~}}
|
||||||
|
|
||||||
|
<Secrets::Page::Destinations::CreateAndEdit @destination={{this.model}} />
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{{!
|
||||||
|
Copyright (c) HashiCorp, Inc.
|
||||||
|
SPDX-License-Identifier: BUSL-1.1
|
||||||
|
~}}
|
||||||
|
|
||||||
|
<Secrets::Page::Destinations::SelectType />
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{{!
|
||||||
|
Copyright (c) HashiCorp, Inc.
|
||||||
|
SPDX-License-Identifier: BUSL-1.1
|
||||||
|
~}}
|
||||||
|
|
||||||
|
<Secrets::Page::Destinations::Destination::Details @destination={{this.model}} />
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{{!
|
||||||
|
Copyright (c) HashiCorp, Inc.
|
||||||
|
SPDX-License-Identifier: BUSL-1.1
|
||||||
|
~}}
|
||||||
|
|
||||||
|
<Secrets::Page::Destinations::CreateAndEdit @destination={{this.model}} />
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{{!
|
||||||
|
Copyright (c) HashiCorp, Inc.
|
||||||
|
SPDX-License-Identifier: BUSL-1.1
|
||||||
|
~}}
|
||||||
|
|
||||||
|
<Secrets::Page::Destinations::Destination::Secrets
|
||||||
|
@destination={{this.model.destination}}
|
||||||
|
@associations={{this.model.associations}}
|
||||||
|
/>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{{!
|
||||||
|
Copyright (c) HashiCorp, Inc.
|
||||||
|
SPDX-License-Identifier: BUSL-1.1
|
||||||
|
~}}
|
||||||
|
|
||||||
|
<Secrets::Page::Destinations::Destination::Sync @destination={{this.model}} />
|
||||||
10
ui/lib/sync/addon/templates/secrets/destinations/index.hbs
Normal file
10
ui/lib/sync/addon/templates/secrets/destinations/index.hbs
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{{!
|
||||||
|
Copyright (c) HashiCorp, Inc.
|
||||||
|
SPDX-License-Identifier: BUSL-1.1
|
||||||
|
~}}
|
||||||
|
|
||||||
|
<Secrets::Page::Destinations
|
||||||
|
@destinations={{this.model.destinations}}
|
||||||
|
@nameFilter={{this.model.nameFilter}}
|
||||||
|
@typeFilter={{this.model.typeFilter}}
|
||||||
|
/>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{{!
|
||||||
|
Copyright (c) HashiCorp, Inc.
|
||||||
|
SPDX-License-Identifier: BUSL-1.1
|
||||||
|
~}}
|
||||||
|
|
||||||
|
<LayoutLoading />
|
||||||
9
ui/lib/sync/addon/templates/secrets/overview.hbs
Normal file
9
ui/lib/sync/addon/templates/secrets/overview.hbs
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{{!
|
||||||
|
Copyright (c) HashiCorp, Inc.
|
||||||
|
SPDX-License-Identifier: BUSL-1.1
|
||||||
|
~}}
|
||||||
|
|
||||||
|
<Secrets::Page::Overview
|
||||||
|
@destinations={{this.model.destinations}}
|
||||||
|
@totalAssociations={{this.model.associations.total_associations}}
|
||||||
|
/>
|
||||||
16
ui/lib/sync/config/environment.js
Normal file
16
ui/lib/sync/config/environment.js
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) HashiCorp, Inc.
|
||||||
|
* SPDX-License-Identifier: BUSL-1.1
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* eslint-env node */
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
module.exports = function (environment) {
|
||||||
|
const ENV = {
|
||||||
|
modulePrefix: 'sync',
|
||||||
|
environment,
|
||||||
|
};
|
||||||
|
|
||||||
|
return ENV;
|
||||||
|
};
|
||||||
22
ui/lib/sync/index.js
Normal file
22
ui/lib/sync/index.js
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) HashiCorp, Inc.
|
||||||
|
* SPDX-License-Identifier: BUSL-1.1
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* eslint-env node */
|
||||||
|
/* eslint-disable n/no-extraneous-require */
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { buildEngine } = require('ember-engines/lib/engine-addon');
|
||||||
|
|
||||||
|
module.exports = buildEngine({
|
||||||
|
name: 'sync',
|
||||||
|
|
||||||
|
lazyLoading: Object.freeze({
|
||||||
|
enabled: false,
|
||||||
|
}),
|
||||||
|
|
||||||
|
isDevelopingAddon() {
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
});
|
||||||
29
ui/lib/sync/package.json
Normal file
29
ui/lib/sync/package.json
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"name": "sync",
|
||||||
|
"keywords": [
|
||||||
|
"ember-addon",
|
||||||
|
"ember-engine"
|
||||||
|
],
|
||||||
|
"dependencies": {
|
||||||
|
"@hashicorp/design-system-components": "*",
|
||||||
|
"ember-cli-htmlbars": "*",
|
||||||
|
"ember-cli-babel": "*",
|
||||||
|
"ember-concurrency": "*",
|
||||||
|
"@ember/test-waiters": "*",
|
||||||
|
"ember-cli-typescript": "*",
|
||||||
|
"@types/ember": "latest",
|
||||||
|
"@types/ember-data": "latest",
|
||||||
|
"@types/ember-data__store": "latest",
|
||||||
|
"@types/ember__array": "latest",
|
||||||
|
"@types/ember__component": "latest",
|
||||||
|
"@types/ember__controller": "latest",
|
||||||
|
"@types/ember__engine": "latest",
|
||||||
|
"@types/ember__routing": "latest",
|
||||||
|
"@types/rsvp": "latest"
|
||||||
|
},
|
||||||
|
"ember-addon": {
|
||||||
|
"paths": [
|
||||||
|
"../core"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
17
ui/mirage/factories/sync-association.js
Normal file
17
ui/mirage/factories/sync-association.js
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) HashiCorp, Inc.
|
||||||
|
* SPDX-License-Identifier: BUSL-1.1
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Factory } from 'ember-cli-mirage';
|
||||||
|
|
||||||
|
export default Factory.extend({
|
||||||
|
accessor: 'kv_eb4acbae', // mount will be added to API response for use in the ui but leaving since it is a documented property
|
||||||
|
mount: 'my-kv',
|
||||||
|
secret_name: 'my-path/my-secret-1',
|
||||||
|
sync_status: 'SYNCED',
|
||||||
|
updated_at: '2023-09-20T10:51:53.961861096-04:00',
|
||||||
|
// set on create for lookup by destination
|
||||||
|
type: null,
|
||||||
|
name: null,
|
||||||
|
});
|
||||||
46
ui/mirage/factories/sync-destination.js
Normal file
46
ui/mirage/factories/sync-destination.js
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) HashiCorp, Inc.
|
||||||
|
* SPDX-License-Identifier: BUSL-1.1
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Factory, trait } from 'ember-cli-mirage';
|
||||||
|
|
||||||
|
export default Factory.extend({
|
||||||
|
['aws-sm']: trait({
|
||||||
|
type: 'aws-sm',
|
||||||
|
name: 'destination-aws',
|
||||||
|
access_key_id: '*****',
|
||||||
|
secret_access_key: '*****',
|
||||||
|
region: 'us-west-1',
|
||||||
|
}),
|
||||||
|
['azure-kv']: trait({
|
||||||
|
type: 'azure-kv',
|
||||||
|
name: 'destination-azure',
|
||||||
|
key_vault_uri: 'https://keyvault-1234abcd.vault.azure.net',
|
||||||
|
subscription_id: 'subscription-id',
|
||||||
|
tenant_id: 'tenant-id',
|
||||||
|
client_id: 'azure-client-id',
|
||||||
|
client_secret: '*****',
|
||||||
|
}),
|
||||||
|
['gcp-sm']: trait({
|
||||||
|
type: 'gcp-sm',
|
||||||
|
name: 'destination-gcp',
|
||||||
|
credentials: '*****',
|
||||||
|
project_id: 'gcp-project-id', // TODO backend will add, doesn't exist yet
|
||||||
|
}),
|
||||||
|
gh: trait({
|
||||||
|
type: 'gh',
|
||||||
|
name: 'destination-gh',
|
||||||
|
access_token: '*****',
|
||||||
|
repository_owner: 'my-organization-or-username',
|
||||||
|
repository_name: 'my-repository',
|
||||||
|
}),
|
||||||
|
['vercel-project']: trait({
|
||||||
|
type: 'vercel-project',
|
||||||
|
name: 'destination-vercel',
|
||||||
|
access_token: '*****',
|
||||||
|
project_id: 'prj_12345',
|
||||||
|
team_id: 'team_12345',
|
||||||
|
deployment_environments: 'development,preview', // 'production' is also an option, but left out for testing to assert form changes value
|
||||||
|
}),
|
||||||
|
});
|
||||||
@@ -17,6 +17,7 @@ import mfaConfig from './mfa-config';
|
|||||||
import mfaLogin from './mfa-login';
|
import mfaLogin from './mfa-login';
|
||||||
import oidcConfig from './oidc-config';
|
import oidcConfig from './oidc-config';
|
||||||
import reducedDisclosure from './reduced-disclosure';
|
import reducedDisclosure from './reduced-disclosure';
|
||||||
|
import sync from './sync';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
base,
|
base,
|
||||||
@@ -31,4 +32,5 @@ export {
|
|||||||
mfaLogin,
|
mfaLogin,
|
||||||
oidcConfig,
|
oidcConfig,
|
||||||
reducedDisclosure,
|
reducedDisclosure,
|
||||||
|
sync,
|
||||||
};
|
};
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user