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 = [
{