From 7caa1d46cbc01f4cb34b4276c826f18b38d5ef1f Mon Sep 17 00:00:00 2001 From: Jordan Reimer Date: Thu, 7 Mar 2024 14:57:05 -0700 Subject: [PATCH] Sync Enable Feature Workflow (#25739) * adds modal for enabling sync in landing page cta workflow * adds config endpoint to sync mirage handler * update checkbox copy * handle adapter error and modify endpoints * address pr changes * add banner for when not opted in and update tests * change adapterError with verb to clarify boolean * update small tests changes * fix linting js errors * remove empty payload and update banner text * fix problematic test solve for another day * fix test --------- Co-authored-by: Angel Garbarino Co-authored-by: Angel Garbarino --- .../addon/components/secrets/landing-cta.hbs | 9 +++ .../components/secrets/page/overview.hbs | 62 ++++++++++++++++--- .../addon/components/secrets/page/overview.ts | 40 +++++++++++- ui/lib/sync/addon/routes/secrets.ts | 45 ++++++++++++++ ui/lib/sync/addon/routes/secrets/overview.ts | 5 ++ .../sync/addon/templates/secrets/overview.hbs | 2 + ui/mirage/handlers/sync.js | 10 +++ .../sync/secrets/destinations-test.js | 21 +++++++ ui/tests/helpers/sync/sync-selectors.js | 5 ++ .../sync/secrets/landing-cta-test.js | 40 +++++++----- .../sync/secrets/page/overview-test.js | 41 ++++++++---- 11 files changed, 239 insertions(+), 41 deletions(-) create mode 100644 ui/lib/sync/addon/routes/secrets.ts diff --git a/ui/lib/sync/addon/components/secrets/landing-cta.hbs b/ui/lib/sync/addon/components/secrets/landing-cta.hbs index c7a3096bc1..33acab84c8 100644 --- a/ui/lib/sync/addon/components/secrets/landing-cta.hbs +++ b/ui/lib/sync/addon/components/secrets/landing-cta.hbs @@ -3,6 +3,15 @@ SPDX-License-Identifier: BUSL-1.1 ~}} + + <:actions> + {{! Only allow users to create a destination if secrets-sync is activated }} + {{#if (and this.version.isEnterprise @isActivated)}} + + {{/if}} + + +
{{#if this.version.isEnterprise}}

diff --git a/ui/lib/sync/addon/components/secrets/page/overview.hbs b/ui/lib/sync/addon/components/secrets/page/overview.hbs index 6c5644e21e..aaa8ebca5c 100644 --- a/ui/lib/sync/addon/components/secrets/page/overview.hbs +++ b/ui/lib/sync/addon/components/secrets/page/overview.hbs @@ -3,15 +3,27 @@ SPDX-License-Identifier: BUSL-1.1 ~}} - - <:actions> - {{#if (and this.version.isEnterprise (not @destinations))}} - - {{/if}} - - +{{#unless this.isActivated}} + + Enable secrets sync feature + To use this feature, specific activation is required. Please review the feature documentation and enable + it. If you're upgrading from beta, your previous data will be accessible after activation. + + +{{/unless}} +{{! show error if call to activated endpoint fails }} +{{#if @isAdapterError}} + +{{/if}} {{#if @destinations}} + +

{{else}} - + +{{/if}} + +{{#if this.showActivateSecretsSyncModal}} + + + Enable secrets sync feature + + +

+ Before using this feature, we want to make sure you’ve carefully read the document around the billing and client + count impact. + Docs here. +

+ + I've read the above linked document + +
+ + + + + + +
{{/if}} \ No newline at end of file diff --git a/ui/lib/sync/addon/components/secrets/page/overview.ts b/ui/lib/sync/addon/components/secrets/page/overview.ts index b0edaff33d..b4a6456bdf 100644 --- a/ui/lib/sync/addon/components/secrets/page/overview.ts +++ b/ui/lib/sync/addon/components/secrets/page/overview.ts @@ -7,28 +7,36 @@ import Component from '@glimmer/component'; import { tracked } from '@glimmer/tracking'; import { service } from '@ember/service'; import { task } from 'ember-concurrency'; +import { waitFor } from '@ember/test-waiters'; +import { action } from '@ember/object'; +import errorMessage from 'vault/utils/error-message'; import Ember from 'ember'; import type FlashMessageService from 'vault/services/flash-messages'; -import type RouterService from '@ember/routing/router-service'; import type StoreService from 'vault/services/store'; +import type RouterService from '@ember/routing/router-service'; import type VersionService from 'vault/services/version'; import type { SyncDestinationAssociationMetrics } from 'vault/vault/adapters/sync/association'; import type SyncDestinationModel from 'vault/vault/models/sync/destination'; +import type { HTMLElementEvent } from 'vault/forms'; interface Args { destinations: Array; - totalAssociations: number; + totalVaultSecrets: number; + activatedFeatures: Array; + isAdapterError: boolean; } export default class SyncSecretsDestinationsPageComponent extends Component { @service declare readonly flashMessages: FlashMessageService; - @service declare readonly router: RouterService; @service declare readonly store: StoreService; + @service declare readonly router: RouterService; @service declare readonly version: VersionService; @tracked destinationMetrics: SyncDestinationAssociationMetrics[] = []; @tracked page = 1; + @tracked showActivateSecretsSyncModal = false; + @tracked confirmDisabled = true; pageSize = Ember.testing ? 3 : 5; // lower in tests to test pagination without seeding more data @@ -39,6 +47,13 @@ export default class SyncSecretsDestinationsPageComponent extends Component { try { const total = page * this.pageSize; @@ -51,4 +66,23 @@ export default class SyncSecretsDestinationsPageComponent extends Component) { + this.confirmDisabled = !event.target.checked; + } + + @task + @waitFor + *onFeatureConfirm() { + try { + yield this.store + .adapterFor('application') + .ajax('/v1/sys/activation-flags/secrets-sync/activate', 'POST'); + this.showActivateSecretsSyncModal = false; + this.router.transitionTo('vault.cluster.sync.secrets.overview'); + } catch (error) { + this.flashMessages.danger(`Error enabling feature \n ${errorMessage(error)}`); + } + } } diff --git a/ui/lib/sync/addon/routes/secrets.ts b/ui/lib/sync/addon/routes/secrets.ts new file mode 100644 index 0000000000..52a6dc97dd --- /dev/null +++ b/ui/lib/sync/addon/routes/secrets.ts @@ -0,0 +1,45 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import Route from '@ember/routing/route'; +import { service } from '@ember/service'; +import { hash } from 'rsvp'; + +import type RouterService from '@ember/routing/router-service'; +import type StoreService from 'vault/services/store'; +import type AdapterError from '@ember-data/adapter'; + +interface ActivationFlagsResponse { + data: { + activated: Array; + unactivated: Array; + }; +} + +export default class SyncSecretsRoute extends Route { + @service declare readonly router: RouterService; + @service declare readonly store: StoreService; + + model() { + return hash({ + activatedFeatures: this.store + .adapterFor('application') + .ajax('/v1/sys/activation-flags', 'GET') + .then((resp: ActivationFlagsResponse) => { + return resp.data.activated; + }) + .catch((error: AdapterError) => { + // we break out this error while passing args to the component and handle the error in the overview template + return error; + }), + }); + } + + afterModel(model: { activatedFeatures: Array | AdapterError }) { + if (!model.activatedFeatures) { + this.router.transitionTo('vault.cluster.sync.secrets.overview'); + } + } +} diff --git a/ui/lib/sync/addon/routes/secrets/overview.ts b/ui/lib/sync/addon/routes/secrets/overview.ts index 07142cca65..4c088a7b53 100644 --- a/ui/lib/sync/addon/routes/secrets/overview.ts +++ b/ui/lib/sync/addon/routes/secrets/overview.ts @@ -8,17 +8,22 @@ import { service } from '@ember/service'; import { hash } from 'rsvp'; import type StoreService from 'vault/services/store'; +import type AdapterError from '@ember-data/adapter'; export default class SyncSecretsOverviewRoute extends Route { @service declare readonly store: StoreService; async model() { + const { activatedFeatures } = this.modelFor('secrets') as { + activatedFeatures: Array | AdapterError; + }; return hash({ destinations: this.store.query('sync/destination', {}).catch(() => []), associations: this.store .adapterFor('sync/association') .queryAll() .catch(() => []), + activatedFeatures, }); } } diff --git a/ui/lib/sync/addon/templates/secrets/overview.hbs b/ui/lib/sync/addon/templates/secrets/overview.hbs index 72f31cc6e4..21e8ea4e81 100644 --- a/ui/lib/sync/addon/templates/secrets/overview.hbs +++ b/ui/lib/sync/addon/templates/secrets/overview.hbs @@ -6,4 +6,6 @@ \ No newline at end of file diff --git a/ui/mirage/handlers/sync.js b/ui/mirage/handlers/sync.js index 2804243b9a..cf1eec2639 100644 --- a/ui/mirage/handlers/sync.js +++ b/ui/mirage/handlers/sync.js @@ -116,6 +116,16 @@ const createOrUpdateDestination = (schema, req) => { }; export default function (server) { + // default to activated + server.get('/sys/activation-flags', () => { + return { + data: { + activated: ['secrets-sync'], + unactivated: [''], + }, + }; + }); + const base = '/sys/sync/destinations'; const uri = `${base}/:type/:name`; diff --git a/ui/tests/acceptance/sync/secrets/destinations-test.js b/ui/tests/acceptance/sync/secrets/destinations-test.js index 6885acd5fe..064008d3d4 100644 --- a/ui/tests/acceptance/sync/secrets/destinations-test.js +++ b/ui/tests/acceptance/sync/secrets/destinations-test.js @@ -26,6 +26,27 @@ module('Acceptance | sync | destinations', function (hooks) { return authPage.login(); }); + test('it should show opt-in banner and modal if secrets-sync is not activated', async function (assert) { + assert.expect(3); + server.get('/sys/activation-flags', () => { + return { + data: { + activated: [''], + unactivated: ['secrets-sync'], + }, + }; + }); + + await visit('vault/sync/secrets/overview'); + assert.dom(ts.overview.optInBanner).exists('Opt-in banner is shown'); + await click(ts.overview.optInBannerEnable); + assert.dom(ts.overview.optInModal).exists('Opt-in modal is shown'); + assert.dom(ts.overview.optInConfirm).isDisabled('Confirm button is disabled when checkbox is unchecked'); + await click(ts.overview.optInCheck); + await click(ts.overview.optInConfirm); + // ARG TODO improve test coverage and try and use API to check if the opt-in was successful + }); + test('it should create new destination', async function (assert) { // remove destinations from mirage so cta shows when 404 is returned this.server.db.syncDestinations.remove(); diff --git a/ui/tests/helpers/sync/sync-selectors.js b/ui/tests/helpers/sync/sync-selectors.js index 7c3afb3b72..ac8428cd71 100644 --- a/ui/tests/helpers/sync/sync-selectors.js +++ b/ui/tests/helpers/sync/sync-selectors.js @@ -51,6 +51,11 @@ export const PAGE = { }, }, overview: { + optInBanner: '[data-test-secrets-sync-opt-in-banner]', + optInBannerEnable: '[data-test-secrets-sync-opt-in-banner-enable]', + optInModal: '[data-test-secrets-sync-opt-in-modal]', + optInCheck: '[data-test-opt-in-check]', + optInConfirm: '[data-test-opt-in-confirm]', createDestination: '[data-test-create-destination]', table: { row: '[data-test-overview-table-row]', diff --git a/ui/tests/integration/components/sync/secrets/landing-cta-test.js b/ui/tests/integration/components/sync/secrets/landing-cta-test.js index 68a0859f3f..c40bb95711 100644 --- a/ui/tests/integration/components/sync/secrets/landing-cta-test.js +++ b/ui/tests/integration/components/sync/secrets/landing-cta-test.js @@ -6,47 +6,53 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'ember-qunit'; import { setupEngine } from 'ember-engines/test-support'; +import { setupMirage } from 'ember-cli-mirage/test-support'; import hbs from 'htmlbars-inline-precompile'; import { render } from '@ember/test-helpers'; import { PAGE } from 'vault/tests/helpers/sync/sync-selectors'; +import sinon from 'sinon'; + +const { cta } = PAGE; module('Integration | Component | sync | Secrets::LandingCta', function (hooks) { setupRenderingTest(hooks); setupEngine(hooks, 'sync'); + setupMirage(hooks); + hooks.beforeEach(function () { this.version = this.owner.lookup('service:version'); + this.transitionStub = sinon.stub(this.owner.lookup('service:router'), 'transitionTo'); + + this.renderComponent = () => + render( + hbs` + + `, + { owner: this.engine } + ); }); test('it should render promotional copy for community version', async function (assert) { - await render( - hbs` - - `, - { owner: this.engine } - ); + await this.renderComponent(); assert - .dom(PAGE.cta.summary) + .dom(cta.summary) .hasText( 'This enterprise feature allows you to sync secrets to platforms and tools across your stack to get secrets when and where you need them. Learn more about secrets sync' ); - assert.dom(PAGE.cta.link).hasText('Learn more about secrets sync'); + assert.dom(cta.link).hasText('Learn more about secrets sync'); }); - test('it should render enterprise copy', async function (assert) { + test('it should render enterprise copy and action', async function (assert) { this.version.type = 'enterprise'; - await render( - hbs` - - `, - { owner: this.engine } - ); + + await this.renderComponent(); assert - .dom(PAGE.cta.summary) + .dom(cta.summary) .hasText( 'Sync secrets to platforms and tools across your stack to get secrets when and where you need them. Secrets sync tutorial' ); - assert.dom(PAGE.cta.link).hasText('Secrets sync tutorial'); + assert.dom(cta.link).hasText('Secrets sync tutorial'); }); }); diff --git a/ui/tests/integration/components/sync/secrets/page/overview-test.js b/ui/tests/integration/components/sync/secrets/page/overview-test.js index abe428b4d2..7a86cb3242 100644 --- a/ui/tests/integration/components/sync/secrets/page/overview-test.js +++ b/ui/tests/integration/components/sync/secrets/page/overview-test.js @@ -41,32 +41,40 @@ module('Integration | Component | sync | Page::Overview', function (hooks) { const store = this.owner.lookup('service:store'); this.destinations = await store.query('sync/destination', {}); + this.activatedFeatures = ['secrets-sync']; - await render( - hbs``, - { - owner: this.engine, - } - ); + this.renderComponent = () => + render( + hbs``, + { + owner: this.engine, + } + ); }); test('it should render landing cta component for community', async function (assert) { this.version.type = 'community'; - this.set('destinations', []); - await settled(); + this.destinations = []; + + await this.renderComponent(); + assert.dom(title).hasText('Secrets Sync Enterprise feature', 'Page title renders'); assert.dom(cta.button).doesNotExist('Create first destination button does not render'); }); test('it should render landing cta component for enterprise', async function (assert) { - this.set('destinations', []); - await settled(); + this.destinations = []; + + await this.renderComponent(); + assert.dom(title).hasText('Secrets Sync', 'Page title renders'); assert.dom(cta.button).hasText('Create first destination', 'CTA action renders'); assert.dom(cta.summary).exists('CTA renders'); }); test('it should render header, tabs and toolbar for overview state', async function (assert) { + await this.renderComponent(); + assert.dom(title).hasText('Secrets Sync', 'Page title renders'); assert.dom(breadcrumb).exists({ count: 1 }, 'Correct number of breadcrumbs render'); assert.dom(breadcrumb).includesText('Secrets Sync', 'Top level breadcrumb renders'); @@ -82,6 +90,9 @@ module('Integration | Component | sync | Page::Overview', function (hooks) { [new Date('2023-09-20T10:51:53.961861096-04:00'), 'MMMM do yyyy, h:mm:ss a'], {} ); + + await this.renderComponent(); + assert .dom(overviewCard.title('Secrets by destination')) .hasText('Secrets by destination', 'Overview card title renders for table'); @@ -110,6 +121,8 @@ module('Integration | Component | sync | Page::Overview', function (hooks) { }); test('it should paginate secrets by destination table', async function (assert) { + await this.renderComponent(); + const { name, row } = overview.table; assert.dom(row).exists({ count: 3 }, 'Correct number of table rows render based on page size'); assert.dom(name(0)).hasText('destination-aws', 'First destination renders on page 1'); @@ -124,9 +137,9 @@ module('Integration | Component | sync | Page::Overview', function (hooks) { this.server.get('/sys/sync/destinations/:type/:name/associations', () => { return new Response(403, {}, { errors: ['Permission denied'] }); }); - // since the request resolved trigger a page change and return an error from the associations endpoint - await click(pagination.next); - await settled(); + + await this.renderComponent(); + assert.dom(emptyStateTitle).hasText('Error fetching information', 'Empty state title renders'); assert .dom(emptyStateMessage) @@ -134,6 +147,8 @@ module('Integration | Component | sync | Page::Overview', function (hooks) { }); test('it should render totals cards', async function (assert) { + await this.renderComponent(); + const { title, description, action, content } = overviewCard; const cardData = [ {