mirror of
				https://github.com/optim-enterprises-bv/vault.git
				synced 2025-10-30 18:17:55 +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,11 +104,15 @@ export default class StoreService extends Store { | ||||
|   filterData(filter, dataset) { | ||||
|     let newData = dataset || []; | ||||
|     if (filter) { | ||||
|       newData = dataset.filter(function (item) { | ||||
|       if (filter instanceof Function) { | ||||
|         newData = filter(dataset); | ||||
|       } else { | ||||
|         newData = dataset.filter((item) => { | ||||
|           const id = item.id || item.name || item; | ||||
|           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
	 Jordan Reimer
					Jordan Reimer