mirror of
https://github.com/optim-enterprises-bv/vault.git
synced 2025-10-29 01:32:33 +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;
|
||||
},
|
||||
|
||||
addHeaders(url, options) {
|
||||
addHeaders(url, options, method) {
|
||||
const token = options.clientToken || this.auth.currentToken;
|
||||
const headers = {};
|
||||
if (token && !options.unauthenticated) {
|
||||
@@ -45,6 +45,9 @@ export default RESTAdapter.extend({
|
||||
if (options.wrapTTL) {
|
||||
headers['X-Vault-Wrap-TTL'] = options.wrapTTL;
|
||||
}
|
||||
if (method === 'PATCH') {
|
||||
headers['Content-Type'] = 'application/merge-patch+json';
|
||||
}
|
||||
const namespace =
|
||||
typeof options.namespace === 'undefined' ? this.namespaceService.path : options.namespace;
|
||||
if (namespace && !NAMESPACE_ROOT_URLS.some((str) => url.includes(str))) {
|
||||
@@ -53,8 +56,8 @@ export default RESTAdapter.extend({
|
||||
options.headers = assign(options.headers || {}, headers);
|
||||
},
|
||||
|
||||
_preRequest(url, options) {
|
||||
this.addHeaders(url, options);
|
||||
_preRequest(url, options, method) {
|
||||
this.addHeaders(url, options, method);
|
||||
const isPolling = POLLING_URLS.some((str) => url.includes(str));
|
||||
if (!isPolling) {
|
||||
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) => {
|
||||
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: {
|
||||
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"
|
||||
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")}}
|
||||
<Nav.Link
|
||||
@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('cluster', { path: '/:cluster_name' }, function () {
|
||||
this.route('dashboard');
|
||||
this.mount('sync');
|
||||
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-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
|
||||
// page: the page number to return
|
||||
// size: the size of the page
|
||||
// pageFilter: a string that will be used to do a fuzzy match against the
|
||||
// results, this is done pre-pagination
|
||||
// pageFilter: a string that will be used to do a fuzzy match against the results,
|
||||
// OR a function to be executed that will receive the dataset as the lone arg.
|
||||
// Filter is done pre-pagination.
|
||||
lazyPaginatedQuery(modelType, query, adapterOptions) {
|
||||
const skipCache = query.skipCache;
|
||||
// We don't want skipCache to be part of the actual query key, so remove it
|
||||
@@ -103,10 +104,14 @@ export default class StoreService extends Store {
|
||||
filterData(filter, dataset) {
|
||||
let newData = dataset || [];
|
||||
if (filter) {
|
||||
newData = dataset.filter(function (item) {
|
||||
const id = item.id || item.name || item;
|
||||
return id.toLowerCase().includes(filter.toLowerCase());
|
||||
});
|
||||
if (filter instanceof Function) {
|
||||
newData = filter(dataset);
|
||||
} else {
|
||||
newData = dataset.filter((item) => {
|
||||
const id = item.id || item.name || item;
|
||||
return id.toLowerCase().includes(filter.toLowerCase());
|
||||
});
|
||||
}
|
||||
}
|
||||
return newData;
|
||||
}
|
||||
|
||||
@@ -20,4 +20,8 @@
|
||||
width: 6.5rem;
|
||||
min-height: 8rem;
|
||||
}
|
||||
|
||||
&.card-width-20 {
|
||||
width: 20rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,7 +44,8 @@
|
||||
.has-error-border,
|
||||
select.has-error-border,
|
||||
.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;
|
||||
}
|
||||
|
||||
|
||||
@@ -53,6 +53,10 @@
|
||||
padding-bottom: $spacing-24;
|
||||
}
|
||||
|
||||
.has-top-padding-xs {
|
||||
padding-top: $spacing-8;
|
||||
}
|
||||
|
||||
.has-top-padding-s {
|
||||
padding-top: $spacing-12;
|
||||
}
|
||||
@@ -167,6 +171,10 @@
|
||||
margin-top: $spacing-48;
|
||||
}
|
||||
|
||||
.has-top-margin-xxxl {
|
||||
margin-top: $spacing-64;
|
||||
}
|
||||
|
||||
.has-top-margin-negative-s {
|
||||
margin-top: (-1 * $spacing-12);
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ $spacing-24: 24px;
|
||||
$spacing-32: 32px;
|
||||
$spacing-36: 36px;
|
||||
$spacing-48: 48px;
|
||||
$spacing-64: 64px;
|
||||
|
||||
/* Border radius */
|
||||
$radius: 2px;
|
||||
|
||||
@@ -43,7 +43,7 @@ The `size` param defaults to the default page size set in [the app config](../co
|
||||
|
||||
### 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
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
## Add engine path to ember-addon section of main app package.json
|
||||
|
||||
```json
|
||||
"ember-addon": {
|
||||
"paths": [
|
||||
"lib/core",
|
||||
"lib/your-new-engine"
|
||||
]
|
||||
},
|
||||
```
|
||||
|
||||
### Important Notes:
|
||||
|
||||
- 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
|
||||
~}}
|
||||
|
||||
<div class="field">
|
||||
<p class="control has-icons-left">
|
||||
<input
|
||||
class="filter input"
|
||||
placeholder={{this.placeholder}}
|
||||
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 class="control {{unless @hideIcon 'has-icons-left'}}" data-test-filter-input-container>
|
||||
<input class="filter input" ...attributes {{on "input" this.onInput}} {{did-insert this.focus}} data-test-filter-input />
|
||||
{{#unless @hideIcon}}
|
||||
<Icon @name="search" class="search-icon has-text-grey-light" data-test-filter-input-icon />
|
||||
{{/unless}}
|
||||
</div>
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: MPL-2.0
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import Component from '@glimmer/component';
|
||||
@@ -10,25 +10,13 @@ import { debounce, next } from '@ember/runloop';
|
||||
import type { HTMLElementEvent } from 'vault/forms';
|
||||
|
||||
interface Args {
|
||||
value?: string; // initial value
|
||||
placeholder?: string; // defaults to Type to filter results
|
||||
wait?: number; // defaults to 200
|
||||
wait?: number; // defaults to 500
|
||||
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> {
|
||||
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
|
||||
focus(elem: HTMLElement) {
|
||||
if (this.args.autofocus) {
|
||||
@@ -38,11 +26,10 @@ export default class FilterInputComponent extends Component<Args> {
|
||||
|
||||
@action
|
||||
onInput(event: HTMLElementEvent<HTMLInputElement>) {
|
||||
const callback = () => {
|
||||
this.args.onInput(event.target.value);
|
||||
};
|
||||
const wait = this.args.wait || 200;
|
||||
const wait = this.args.wait || 500;
|
||||
// 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>
|
||||
{{/each}}
|
||||
</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}}
|
||||
<div class="control is-expanded">
|
||||
<div class="select is-fullwidth">
|
||||
@@ -101,7 +116,7 @@
|
||||
/>
|
||||
{{else if (eq @attr.options.editType "file")}}
|
||||
{{! File Input }}
|
||||
<div class="has-bottom-margin-m">
|
||||
<div class="has-bottom-margin-m" data-test-input={{@attr.name}}>
|
||||
<TextFile
|
||||
@label={{this.labelString}}
|
||||
@subText={{@attr.options.subText}}
|
||||
@@ -325,7 +340,7 @@
|
||||
@type="danger"
|
||||
@message={{this.validationError}}
|
||||
@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"}}
|
||||
/>
|
||||
{{/if}}
|
||||
@@ -334,7 +349,7 @@
|
||||
@type="warning"
|
||||
@message={{this.validationWarning}}
|
||||
@paddingTop={{if (and (not this.validationError) (eq @attr.options.editType "ttl")) false true}}
|
||||
data-test-validation-warning={{@attr.name}}
|
||||
data-test-validation-warning={{this.valuePath}}
|
||||
class={{if (and (not this.validationError) (eq @attr.options.editType "stringArray")) "has-top-margin-negative-xxl"}}
|
||||
/>
|
||||
{{/if}}
|
||||
|
||||
@@ -184,4 +184,12 @@ export default class FormFieldComponent extends Component {
|
||||
const prop = event.target.type === 'checkbox' ? 'checked' : 'value';
|
||||
this.setAndBroadcast(event.target[prop]);
|
||||
}
|
||||
|
||||
@action
|
||||
handleChecklist(event) {
|
||||
const valueArray = this.args.model[this.valuePath];
|
||||
const method = event.target.checked ? 'addObject' : 'removeObject';
|
||||
valueArray[method](event.target.value);
|
||||
this.setAndBroadcast(valueArray);
|
||||
}
|
||||
}
|
||||
|
||||
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"
|
||||
@text={{@actionText}}
|
||||
@route={{@actionTo}}
|
||||
@isRouteExternal={{@actionExternal}}
|
||||
@query={{@actionQuery}}
|
||||
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
|
||||
*/
|
||||
|
||||
export { default } from 'core/helpers/to-label';
|
||||
export { default, toLabel } from 'core/helpers/to-label';
|
||||
|
||||
@@ -20,6 +20,10 @@
|
||||
</p.levelLeft>
|
||||
</PageHeader>
|
||||
|
||||
{{#if (has-block "syncDetails")}}
|
||||
{{yield to="syncDetails"}}
|
||||
{{/if}}
|
||||
|
||||
{{#if (has-block "tabLinks")}}
|
||||
<div class="tabs-container box is-bottomless is-marginless is-paddingless">
|
||||
<nav class="tabs" aria-label="kv tabs">
|
||||
|
||||
@@ -4,6 +4,46 @@
|
||||
~}}
|
||||
|
||||
<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>
|
||||
<LinkTo @route="secret.details" data-test-secrets-tab="Secret">Secret</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 wrappedData = null;
|
||||
@tracked syncStatus = null; // array of association sync status info by destination
|
||||
secretDataIsAdvanced;
|
||||
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
this.fetchSyncStatus.perform();
|
||||
this.originalSecret = JSON.stringify(this.args.secret.secretData || {});
|
||||
if (this.originalSecret.lastIndexOf('{') > 0) {
|
||||
// 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
|
||||
async undelete() {
|
||||
const { secret } = this.args;
|
||||
|
||||
@@ -25,7 +25,7 @@ export default class KvEngine extends Engine {
|
||||
'flash-messages',
|
||||
'control-group',
|
||||
],
|
||||
externalRoutes: ['secrets'],
|
||||
externalRoutes: ['secrets', 'syncDestination'],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@ export default class KvSecretDetailsIndexRoute extends Route {
|
||||
{ label: resolvedModel.backend, route: 'list' },
|
||||
...breadcrumbsForSecret(resolvedModel.path, true),
|
||||
];
|
||||
|
||||
controller.breadcrumbs = breadcrumbsArray;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,12 @@
|
||||
<TabPageHeader @model={{@backendModel}} @breadcrumbs={{@breadcrumbs}}>
|
||||
<:toolbarFilters>
|
||||
{{#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}}
|
||||
</:toolbarFilters>
|
||||
<:toolbarActions>
|
||||
|
||||
@@ -6,13 +6,7 @@
|
||||
<TabPageHeader @model={{@backendModel}} @breadcrumbs={{@breadcrumbs}}>
|
||||
<:toolbarFilters>
|
||||
{{#if (and (not @promptConfig) @roles.meta.total)}}
|
||||
<FilterInput
|
||||
@placeholder="Filter roles"
|
||||
@value={{@pageFilter}}
|
||||
@wait={{500}}
|
||||
@autofocus={{true}}
|
||||
@onInput={{this.onFilterChange}}
|
||||
/>
|
||||
<FilterInput placeholder="Filter roles" value={{@pageFilter}} @autofocus={{true}} @onInput={{this.onFilterChange}} />
|
||||
{{/if}}
|
||||
</:toolbarFilters>
|
||||
<: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 oidcConfig from './oidc-config';
|
||||
import reducedDisclosure from './reduced-disclosure';
|
||||
import sync from './sync';
|
||||
|
||||
export {
|
||||
base,
|
||||
@@ -31,4 +32,5 @@ export {
|
||||
mfaLogin,
|
||||
oidcConfig,
|
||||
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