From 99445dbfd493a9bd48d65c73bd734536076f66bc Mon Sep 17 00:00:00 2001 From: Jordan Reimer Date: Wed, 13 Dec 2023 12:16:44 -0700 Subject: [PATCH] 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 * 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 Co-authored-by: Kianna <30884335+kiannaquach@users.noreply.github.com> --- changelog/23667.txt | 3 + ui/app/adapters/application.js | 11 +- ui/app/adapters/sync/association.js | 96 ++++++ ui/app/adapters/sync/destination.js | 43 +++ ui/app/adapters/sync/destinations/aws-sm.js | 8 + ui/app/adapters/sync/destinations/azure-kv.js | 8 + ui/app/adapters/sync/destinations/gcp-sm.js | 8 + ui/app/adapters/sync/destinations/gh.js | 8 + .../sync/destinations/vercel-project.js | 8 + ui/app/app.js | 10 + ui/app/components/sidebar/nav/cluster.hbs | 6 + ui/app/models/sync/association.js | 39 +++ ui/app/models/sync/destination.js | 53 ++++ ui/app/models/sync/destinations/aws-sm.js | 37 +++ ui/app/models/sync/destinations/azure-kv.js | 51 ++++ ui/app/models/sync/destinations/gcp-sm.js | 24 ++ ui/app/models/sync/destinations/gh.js | 36 +++ .../sync/destinations/vercel-project.js | 70 +++++ ui/app/router.js | 1 + ui/app/serializers/sync/association.js | 64 ++++ ui/app/serializers/sync/destination.js | 65 ++++ .../serializers/sync/destinations/aws-sm.js | 8 + .../serializers/sync/destinations/azure-kv.js | 8 + .../serializers/sync/destinations/gcp-sm.js | 8 + ui/app/serializers/sync/destinations/gh.js | 8 + .../sync/destinations/vercel-project.js | 8 + ui/app/services/store.js | 17 +- ui/app/styles/components/selectable-card.scss | 4 + ui/app/styles/helper-classes/colors.scss | 3 +- ui/app/styles/helper-classes/spacing.scss | 8 + ui/app/styles/utils/_size_variables.scss | 1 + ui/docs/client-pagination.md | 2 +- ui/docs/ember-engines.md | 11 + ui/lib/core/addon/components/filter-input.hbs | 17 +- ui/lib/core/addon/components/filter-input.ts | 29 +- ui/lib/core/addon/components/form-field.hbs | 21 +- ui/lib/core/addon/components/form-field.js | 8 + .../addon/components/kv-suggestion-input.hbs | 35 +++ .../addon/components/kv-suggestion-input.ts | 145 +++++++++ .../core/addon/components/overview-card.hbs | 1 + .../addon/components/sync-status-badge.hbs | 6 + .../addon/components/sync-status-badge.ts | 62 ++++ .../core/addon/helpers/sync-destinations.ts | 66 +++++ .../app/components/kv-suggestion-input.js | 6 + .../core/app/components/sync-status-badge.js | 6 + ui/lib/core/app/helpers/sync-destinations.js | 6 + ui/lib/core/app/helpers/to-label.js | 2 +- ui/lib/kv/addon/components/kv-page-header.hbs | 4 + .../addon/components/page/secret/details.hbs | 40 +++ .../addon/components/page/secret/details.js | 14 + ui/lib/kv/addon/engine.js | 2 +- .../kv/addon/routes/secret/details/index.js | 1 - .../ldap/addon/components/page/libraries.hbs | 7 +- ui/lib/ldap/addon/components/page/roles.hbs | 8 +- .../components/secrets/destination-header.hbs | 75 +++++ .../components/secrets/destination-header.ts | 38 +++ .../addon/components/secrets/landing-cta.hbs | 48 +++ .../addon/components/secrets/landing-cta.ts | 13 + .../components/secrets/page/destinations.hbs | 124 ++++++++ .../components/secrets/page/destinations.ts | 94 ++++++ .../page/destinations/create-and-edit.hbs | 60 ++++ .../page/destinations/create-and-edit.ts | 103 +++++++ .../page/destinations/destination/details.hbs | 18 ++ .../page/destinations/destination/details.ts | 18 ++ .../page/destinations/destination/secrets.hbs | 90 ++++++ .../page/destinations/destination/secrets.ts | 50 ++++ .../page/destinations/destination/sync.hbs | 88 ++++++ .../page/destinations/destination/sync.ts | 97 ++++++ .../secrets/page/destinations/select-type.hbs | 33 +++ .../components/secrets/page/overview.hbs | 157 ++++++++++ .../addon/components/secrets/page/overview.ts | 52 ++++ ui/lib/sync/addon/components/sync-header.hbs | 26 ++ ui/lib/sync/addon/components/sync-header.ts | 20 ++ .../destinations/destination/secrets.ts | 10 + .../controllers/secrets/destinations/index.ts | 10 + ui/lib/sync/addon/engine.js | 22 ++ ui/lib/sync/addon/routes.js | 23 ++ ui/lib/sync/addon/routes/index.ts | 17 ++ .../destinations/create/destination.ts | 22 ++ .../secrets/destinations/destination.ts | 23 ++ .../secrets/destinations/destination/index.ts | 17 ++ .../destinations/destination/secrets.ts | 32 ++ .../routes/secrets/destinations/index.ts | 50 ++++ ui/lib/sync/addon/routes/secrets/overview.ts | 24 ++ ui/lib/sync/addon/templates/error.hbs | 8 + .../destinations/create/destination.hbs | 6 + .../secrets/destinations/create/index.hbs | 6 + .../destinations/destination/details.hbs | 6 + .../secrets/destinations/destination/edit.hbs | 6 + .../destinations/destination/secrets.hbs | 9 + .../secrets/destinations/destination/sync.hbs | 6 + .../templates/secrets/destinations/index.hbs | 10 + .../secrets/destinations/loading.hbs | 6 + .../sync/addon/templates/secrets/overview.hbs | 9 + ui/lib/sync/config/environment.js | 16 + ui/lib/sync/index.js | 22 ++ ui/lib/sync/package.json | 29 ++ ui/mirage/factories/sync-association.js | 17 ++ ui/mirage/factories/sync-destination.js | 46 +++ ui/mirage/handlers/index.js | 2 + ui/mirage/handlers/sync.js | 190 ++++++++++++ ui/mirage/scenarios/index.js | 3 +- ui/mirage/scenarios/sync.js | 23 ++ ui/package.json | 3 +- ui/public/images/sync-landing-1.png | Bin 0 -> 167614 bytes ui/public/images/sync-landing-2.png | Bin 0 -> 145278 bytes .../acceptance/enterprise-sidebar-nav-test.js | 3 + .../sync/secrets/destinations-test.js | 34 +++ ui/tests/helpers/general-selectors.js | 49 +++ ui/tests/helpers/kv/kv-selectors.js | 1 + ui/tests/helpers/sync/setup-models.js | 53 ++++ ui/tests/helpers/sync/sync-selectors.js | 82 ++++++ .../components/filter-input-test.js | 35 ++- .../components/kv-suggestion-input-test.js | 122 ++++++++ .../kv/page/kv-page-secret-details-test.js | 51 +++- .../components/sidebar/nav/cluster-test.js | 3 +- .../components/sync-status-badge-test.js | 56 ++++ .../sync/secrets/destination-header-test.js | 72 +++++ .../sync/secrets/landing-cta-test.js | 52 ++++ .../sync/secrets/page/destinations-test.js | 161 ++++++++++ .../page/destinations/create-and-edit-test.js | 278 ++++++++++++++++++ .../destinations/destination/details-test.js | 118 ++++++++ .../destinations/destination/secrets-test.js | 85 ++++++ .../destinations/destination/sync-test.js | 164 +++++++++++ .../page/destinations/select-type-test.js | 40 +++ .../sync/secrets/page/overview-test.js | 148 ++++++++++ .../components/sync/sync-header-test.js | 67 +++++ .../unit/adapters/sync/associations-test.js | 188 ++++++++++++ .../unit/adapters/sync/destinations-test.js | 167 +++++++++++ .../serializers/sync/associations-test.js | 87 ++++++ .../serializers/sync/destinations-test.js | 70 +++++ ui/tests/unit/services/store-test.js | 94 +++--- ui/tsconfig.json | 3 + .../ember-data/types/registries/adapter.d.ts | 4 + .../ember-data/types/registries/model.d.ts | 2 +- ui/types/vault/adapters/sync/association.d.ts | 35 +++ ui/types/vault/adapters/sync/destination.d.ts | 17 ++ ui/types/vault/app-types.ts | 21 ++ ui/types/vault/helpers/sync-destinations.ts | 20 ++ ui/types/vault/models/kv/metadata.d.ts | 7 +- ui/types/vault/models/sync/association.d.ts | 18 ++ ui/types/vault/models/sync/destination.d.ts | 40 +++ 142 files changed, 5438 insertions(+), 107 deletions(-) create mode 100644 changelog/23667.txt create mode 100644 ui/app/adapters/sync/association.js create mode 100644 ui/app/adapters/sync/destination.js create mode 100644 ui/app/adapters/sync/destinations/aws-sm.js create mode 100644 ui/app/adapters/sync/destinations/azure-kv.js create mode 100644 ui/app/adapters/sync/destinations/gcp-sm.js create mode 100644 ui/app/adapters/sync/destinations/gh.js create mode 100644 ui/app/adapters/sync/destinations/vercel-project.js create mode 100644 ui/app/models/sync/association.js create mode 100644 ui/app/models/sync/destination.js create mode 100644 ui/app/models/sync/destinations/aws-sm.js create mode 100644 ui/app/models/sync/destinations/azure-kv.js create mode 100644 ui/app/models/sync/destinations/gcp-sm.js create mode 100644 ui/app/models/sync/destinations/gh.js create mode 100644 ui/app/models/sync/destinations/vercel-project.js create mode 100644 ui/app/serializers/sync/association.js create mode 100644 ui/app/serializers/sync/destination.js create mode 100644 ui/app/serializers/sync/destinations/aws-sm.js create mode 100644 ui/app/serializers/sync/destinations/azure-kv.js create mode 100644 ui/app/serializers/sync/destinations/gcp-sm.js create mode 100644 ui/app/serializers/sync/destinations/gh.js create mode 100644 ui/app/serializers/sync/destinations/vercel-project.js create mode 100644 ui/lib/core/addon/components/kv-suggestion-input.hbs create mode 100644 ui/lib/core/addon/components/kv-suggestion-input.ts create mode 100644 ui/lib/core/addon/components/sync-status-badge.hbs create mode 100644 ui/lib/core/addon/components/sync-status-badge.ts create mode 100644 ui/lib/core/addon/helpers/sync-destinations.ts create mode 100644 ui/lib/core/app/components/kv-suggestion-input.js create mode 100644 ui/lib/core/app/components/sync-status-badge.js create mode 100644 ui/lib/core/app/helpers/sync-destinations.js create mode 100644 ui/lib/sync/addon/components/secrets/destination-header.hbs create mode 100644 ui/lib/sync/addon/components/secrets/destination-header.ts create mode 100644 ui/lib/sync/addon/components/secrets/landing-cta.hbs create mode 100644 ui/lib/sync/addon/components/secrets/landing-cta.ts create mode 100644 ui/lib/sync/addon/components/secrets/page/destinations.hbs create mode 100644 ui/lib/sync/addon/components/secrets/page/destinations.ts create mode 100644 ui/lib/sync/addon/components/secrets/page/destinations/create-and-edit.hbs create mode 100644 ui/lib/sync/addon/components/secrets/page/destinations/create-and-edit.ts create mode 100644 ui/lib/sync/addon/components/secrets/page/destinations/destination/details.hbs create mode 100644 ui/lib/sync/addon/components/secrets/page/destinations/destination/details.ts create mode 100644 ui/lib/sync/addon/components/secrets/page/destinations/destination/secrets.hbs create mode 100644 ui/lib/sync/addon/components/secrets/page/destinations/destination/secrets.ts create mode 100644 ui/lib/sync/addon/components/secrets/page/destinations/destination/sync.hbs create mode 100644 ui/lib/sync/addon/components/secrets/page/destinations/destination/sync.ts create mode 100644 ui/lib/sync/addon/components/secrets/page/destinations/select-type.hbs create mode 100644 ui/lib/sync/addon/components/secrets/page/overview.hbs create mode 100644 ui/lib/sync/addon/components/secrets/page/overview.ts create mode 100644 ui/lib/sync/addon/components/sync-header.hbs create mode 100644 ui/lib/sync/addon/components/sync-header.ts create mode 100644 ui/lib/sync/addon/controllers/secrets/destinations/destination/secrets.ts create mode 100644 ui/lib/sync/addon/controllers/secrets/destinations/index.ts create mode 100644 ui/lib/sync/addon/engine.js create mode 100644 ui/lib/sync/addon/routes.js create mode 100644 ui/lib/sync/addon/routes/index.ts create mode 100644 ui/lib/sync/addon/routes/secrets/destinations/create/destination.ts create mode 100644 ui/lib/sync/addon/routes/secrets/destinations/destination.ts create mode 100644 ui/lib/sync/addon/routes/secrets/destinations/destination/index.ts create mode 100644 ui/lib/sync/addon/routes/secrets/destinations/destination/secrets.ts create mode 100644 ui/lib/sync/addon/routes/secrets/destinations/index.ts create mode 100644 ui/lib/sync/addon/routes/secrets/overview.ts create mode 100644 ui/lib/sync/addon/templates/error.hbs create mode 100644 ui/lib/sync/addon/templates/secrets/destinations/create/destination.hbs create mode 100644 ui/lib/sync/addon/templates/secrets/destinations/create/index.hbs create mode 100644 ui/lib/sync/addon/templates/secrets/destinations/destination/details.hbs create mode 100644 ui/lib/sync/addon/templates/secrets/destinations/destination/edit.hbs create mode 100644 ui/lib/sync/addon/templates/secrets/destinations/destination/secrets.hbs create mode 100644 ui/lib/sync/addon/templates/secrets/destinations/destination/sync.hbs create mode 100644 ui/lib/sync/addon/templates/secrets/destinations/index.hbs create mode 100644 ui/lib/sync/addon/templates/secrets/destinations/loading.hbs create mode 100644 ui/lib/sync/addon/templates/secrets/overview.hbs create mode 100644 ui/lib/sync/config/environment.js create mode 100644 ui/lib/sync/index.js create mode 100644 ui/lib/sync/package.json create mode 100644 ui/mirage/factories/sync-association.js create mode 100644 ui/mirage/factories/sync-destination.js create mode 100644 ui/mirage/handlers/sync.js create mode 100644 ui/mirage/scenarios/sync.js create mode 100644 ui/public/images/sync-landing-1.png create mode 100644 ui/public/images/sync-landing-2.png create mode 100644 ui/tests/acceptance/sync/secrets/destinations-test.js create mode 100644 ui/tests/helpers/general-selectors.js create mode 100644 ui/tests/helpers/sync/setup-models.js create mode 100644 ui/tests/helpers/sync/sync-selectors.js create mode 100644 ui/tests/integration/components/kv-suggestion-input-test.js create mode 100644 ui/tests/integration/components/sync-status-badge-test.js create mode 100644 ui/tests/integration/components/sync/secrets/destination-header-test.js create mode 100644 ui/tests/integration/components/sync/secrets/landing-cta-test.js create mode 100644 ui/tests/integration/components/sync/secrets/page/destinations-test.js create mode 100644 ui/tests/integration/components/sync/secrets/page/destinations/create-and-edit-test.js create mode 100644 ui/tests/integration/components/sync/secrets/page/destinations/destination/details-test.js create mode 100644 ui/tests/integration/components/sync/secrets/page/destinations/destination/secrets-test.js create mode 100644 ui/tests/integration/components/sync/secrets/page/destinations/destination/sync-test.js create mode 100644 ui/tests/integration/components/sync/secrets/page/destinations/select-type-test.js create mode 100644 ui/tests/integration/components/sync/secrets/page/overview-test.js create mode 100644 ui/tests/integration/components/sync/sync-header-test.js create mode 100644 ui/tests/unit/adapters/sync/associations-test.js create mode 100644 ui/tests/unit/adapters/sync/destinations-test.js create mode 100644 ui/tests/unit/serializers/sync/associations-test.js create mode 100644 ui/tests/unit/serializers/sync/destinations-test.js create mode 100644 ui/types/vault/adapters/sync/association.d.ts create mode 100644 ui/types/vault/adapters/sync/destination.d.ts create mode 100644 ui/types/vault/helpers/sync-destinations.ts create mode 100644 ui/types/vault/models/sync/association.d.ts create mode 100644 ui/types/vault/models/sync/destination.d.ts diff --git a/changelog/23667.txt b/changelog/23667.txt new file mode 100644 index 0000000000..63cd2cf2c3 --- /dev/null +++ b/changelog/23667.txt @@ -0,0 +1,3 @@ +```release-note:feature +**Secrets Sync UI (enterprise)**: Adds secret syncing for KV v2 secrets to external destinations using the UI. +``` \ No newline at end of file diff --git a/ui/app/adapters/application.js b/ui/app/adapters/application.js index a02298456b..751b0bad6e 100644 --- a/ui/app/adapters/application.js +++ b/ui/app/adapters/application.js @@ -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) { diff --git a/ui/app/adapters/sync/association.js b/ui/app/adapters/sync/association.js new file mode 100644 index 0000000000..7057e23927 --- /dev/null +++ b/ui/app/adapters/sync/association.js @@ -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); + } +} diff --git a/ui/app/adapters/sync/destination.js b/ui/app/adapters/sync/destination.js new file mode 100644 index 0000000000..82ea90f7c3 --- /dev/null +++ b/ui/app/adapters/sync/destination.js @@ -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); + } +} diff --git a/ui/app/adapters/sync/destinations/aws-sm.js b/ui/app/adapters/sync/destinations/aws-sm.js new file mode 100644 index 0000000000..f046baa915 --- /dev/null +++ b/ui/app/adapters/sync/destinations/aws-sm.js @@ -0,0 +1,8 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import SyncDestinationAdapter from '../destination'; + +export default class SyncDestinationsAwsSecretsManagerAdapter extends SyncDestinationAdapter {} diff --git a/ui/app/adapters/sync/destinations/azure-kv.js b/ui/app/adapters/sync/destinations/azure-kv.js new file mode 100644 index 0000000000..3b0b61ed93 --- /dev/null +++ b/ui/app/adapters/sync/destinations/azure-kv.js @@ -0,0 +1,8 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import SyncDestinationAdapter from '../destination'; + +export default class SyncDestinationsAzureKeyVaultAdapter extends SyncDestinationAdapter {} diff --git a/ui/app/adapters/sync/destinations/gcp-sm.js b/ui/app/adapters/sync/destinations/gcp-sm.js new file mode 100644 index 0000000000..5229562ab1 --- /dev/null +++ b/ui/app/adapters/sync/destinations/gcp-sm.js @@ -0,0 +1,8 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import SyncDestinationAdapter from '../destination'; + +export default class SyncDestinationGoogleCloudSecretManagerAdapter extends SyncDestinationAdapter {} diff --git a/ui/app/adapters/sync/destinations/gh.js b/ui/app/adapters/sync/destinations/gh.js new file mode 100644 index 0000000000..df83b4229a --- /dev/null +++ b/ui/app/adapters/sync/destinations/gh.js @@ -0,0 +1,8 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import SyncDestinationAdapter from '../destination'; + +export default class SyncDestinationsGithubAdapter extends SyncDestinationAdapter {} diff --git a/ui/app/adapters/sync/destinations/vercel-project.js b/ui/app/adapters/sync/destinations/vercel-project.js new file mode 100644 index 0000000000..253ab72c9a --- /dev/null +++ b/ui/app/adapters/sync/destinations/vercel-project.js @@ -0,0 +1,8 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import SyncDestinationAdapter from '../destination'; + +export default class SyncDestinationsVercelProjectAdapter extends SyncDestinationAdapter {} diff --git a/ui/app/app.js b/ui/app/app.js index 2fe614d04c..ca5ec0a052 100644 --- a/ui/app/app.js +++ b/ui/app/app.js @@ -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', + }, + }, + }, }; } diff --git a/ui/app/components/sidebar/nav/cluster.hbs b/ui/app/components/sidebar/nav/cluster.hbs index bb3c142de0..2b0ecff88d 100644 --- a/ui/app/components/sidebar/nav/cluster.hbs +++ b/ui/app/components/sidebar/nav/cluster.hbs @@ -13,6 +13,12 @@ @text="Secrets Engines" data-test-sidebar-nav-link="Secrets Engines" /> + {{#if (has-permission "access")}} + !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(','); + } +} diff --git a/ui/app/router.js b/ui/app/router.js index 2390c757fb..51396f4870 100644 --- a/ui/app/router.js +++ b/ui/app/router.js @@ -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' }); diff --git a/ui/app/serializers/sync/association.js b/ui/app/serializers/sync/association.js new file mode 100644 index 0000000000..a0f83e1a9e --- /dev/null +++ b/ui/app/serializers/sync/association.js @@ -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, + }; + } +} diff --git a/ui/app/serializers/sync/destination.js b/ui/app/serializers/sync/destination.js new file mode 100644 index 0000000000..8215ea4c50 --- /dev/null +++ b/ui/app/serializers/sync/destination.js @@ -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; + } +} diff --git a/ui/app/serializers/sync/destinations/aws-sm.js b/ui/app/serializers/sync/destinations/aws-sm.js new file mode 100644 index 0000000000..9ca35debaf --- /dev/null +++ b/ui/app/serializers/sync/destinations/aws-sm.js @@ -0,0 +1,8 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import SyncDestinationSerializer from '../destination'; + +export default class SyncDestinationsAwsSecretsManagerSerializer extends SyncDestinationSerializer {} diff --git a/ui/app/serializers/sync/destinations/azure-kv.js b/ui/app/serializers/sync/destinations/azure-kv.js new file mode 100644 index 0000000000..f733cc2b4f --- /dev/null +++ b/ui/app/serializers/sync/destinations/azure-kv.js @@ -0,0 +1,8 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import SyncDestinationSerializer from '../destination'; + +export default class SyncDestinationsAzureKeyVaultSerializer extends SyncDestinationSerializer {} diff --git a/ui/app/serializers/sync/destinations/gcp-sm.js b/ui/app/serializers/sync/destinations/gcp-sm.js new file mode 100644 index 0000000000..d454ee7c77 --- /dev/null +++ b/ui/app/serializers/sync/destinations/gcp-sm.js @@ -0,0 +1,8 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import SyncDestinationSerializer from '../destination'; + +export default class SyncDestinationGoogleCloudSecretManagerSerializer extends SyncDestinationSerializer {} diff --git a/ui/app/serializers/sync/destinations/gh.js b/ui/app/serializers/sync/destinations/gh.js new file mode 100644 index 0000000000..b5f75ad37e --- /dev/null +++ b/ui/app/serializers/sync/destinations/gh.js @@ -0,0 +1,8 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import SyncDestinationSerializer from '../destination'; + +export default class SyncDestinationsGithubSerializer extends SyncDestinationSerializer {} diff --git a/ui/app/serializers/sync/destinations/vercel-project.js b/ui/app/serializers/sync/destinations/vercel-project.js new file mode 100644 index 0000000000..38e16e10b2 --- /dev/null +++ b/ui/app/serializers/sync/destinations/vercel-project.js @@ -0,0 +1,8 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import SyncDestinationSerializer from '../destination'; + +export default class SyncDestinationsVercelProjectSerializer extends SyncDestinationSerializer {} diff --git a/ui/app/services/store.js b/ui/app/services/store.js index f3497eae57..c2bc4fd713 100644 --- a/ui/app/services/store.js +++ b/ui/app/services/store.js @@ -64,8 +64,9 @@ export default class StoreService extends Store { // the array of items will be found // page: the page number to return // size: the size of the page - // pageFilter: a string that will be used to do a fuzzy match against the - // results, this is done pre-pagination + // pageFilter: a string that will be used to do a fuzzy match against the results, + // OR a function to be executed that will receive the dataset as the lone arg. + // Filter is done pre-pagination. lazyPaginatedQuery(modelType, query, adapterOptions) { const skipCache = query.skipCache; // We don't want skipCache to be part of the actual query key, so remove it @@ -103,10 +104,14 @@ export default class StoreService extends Store { filterData(filter, dataset) { let newData = dataset || []; if (filter) { - newData = dataset.filter(function (item) { - const id = item.id || item.name || item; - return id.toLowerCase().includes(filter.toLowerCase()); - }); + if (filter instanceof Function) { + newData = filter(dataset); + } else { + newData = dataset.filter((item) => { + const id = item.id || item.name || item; + return id.toLowerCase().includes(filter.toLowerCase()); + }); + } } return newData; } diff --git a/ui/app/styles/components/selectable-card.scss b/ui/app/styles/components/selectable-card.scss index 3b8ad37971..cb3fef378c 100644 --- a/ui/app/styles/components/selectable-card.scss +++ b/ui/app/styles/components/selectable-card.scss @@ -20,4 +20,8 @@ width: 6.5rem; min-height: 8rem; } + + &.card-width-20 { + width: 20rem; + } } diff --git a/ui/app/styles/helper-classes/colors.scss b/ui/app/styles/helper-classes/colors.scss index 4cf691112b..7f31e1df38 100644 --- a/ui/app/styles/helper-classes/colors.scss +++ b/ui/app/styles/helper-classes/colors.scss @@ -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; } diff --git a/ui/app/styles/helper-classes/spacing.scss b/ui/app/styles/helper-classes/spacing.scss index 3aa227a02c..a208705622 100644 --- a/ui/app/styles/helper-classes/spacing.scss +++ b/ui/app/styles/helper-classes/spacing.scss @@ -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); } diff --git a/ui/app/styles/utils/_size_variables.scss b/ui/app/styles/utils/_size_variables.scss index 2109662eb7..97d1a53203 100644 --- a/ui/app/styles/utils/_size_variables.scss +++ b/ui/app/styles/utils/_size_variables.scss @@ -33,6 +33,7 @@ $spacing-24: 24px; $spacing-32: 32px; $spacing-36: 36px; $spacing-48: 48px; +$spacing-64: 64px; /* Border radius */ $radius: 2px; diff --git a/ui/docs/client-pagination.md b/ui/docs/client-pagination.md index 082e99c597..7fd0197c6c 100644 --- a/ui/docs/client-pagination.md +++ b/ui/docs/client-pagination.md @@ -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 diff --git a/ui/docs/ember-engines.md b/ui/docs/ember-engines.md index e2728d2dcc..56cd5841e3 100644 --- a/ui/docs/ember-engines.md +++ b/ui/docs/ember-engines.md @@ -160,6 +160,17 @@ loadInitializers(App, config.modulePrefix); If you used `ember g in-repo-engine ` to generate the engine’s blueprint, it should have added `this.mount()` to the main app’s `router.js` file (this adds your engine and its associated routes). \*Move `this.mount()` 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! diff --git a/ui/lib/core/addon/components/filter-input.hbs b/ui/lib/core/addon/components/filter-input.hbs index 5b8740c59f..2f01821626 100644 --- a/ui/lib/core/addon/components/filter-input.hbs +++ b/ui/lib/core/addon/components/filter-input.hbs @@ -3,16 +3,9 @@ SPDX-License-Identifier: BUSL-1.1 ~}} -
-

- - -

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