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 <Monkeychip@users.noreply.github.com>
Co-authored-by: Angel Garbarino <argarbarino@gmail.com>
This commit is contained in:
Jordan Reimer
2024-03-07 14:57:05 -07:00
committed by GitHub
parent b5f50d7558
commit 7caa1d46cb
11 changed files with 239 additions and 41 deletions

View File

@@ -3,6 +3,15 @@
SPDX-License-Identifier: BUSL-1.1
~}}
<SyncHeader @title="Secrets Sync">
<:actions>
{{! Only allow users to create a destination if secrets-sync is activated }}
{{#if (and this.version.isEnterprise @isActivated)}}
<Hds::Button @text="Create first destination" @route="secrets.destinations.create" data-test-cta-button />
{{/if}}
</:actions>
</SyncHeader>
<div class="box is-fullwidth is-sideless is-flex-between is-shadowless" data-test-cta-container>
{{#if this.version.isEnterprise}}
<p>

View File

@@ -3,15 +3,27 @@
SPDX-License-Identifier: BUSL-1.1
~}}
<SyncHeader @title="Secrets Sync">
<:actions>
{{#if (and this.version.isEnterprise (not @destinations))}}
<Hds::Button @text="Create first destination" @route="secrets.destinations.create" data-test-cta-button />
{{/if}}
</:actions>
</SyncHeader>
{{#unless this.isActivated}}
<Hds::Alert @type="inline" @color="warning" data-test-secrets-sync-opt-in-banner as |A|>
<A.Title>Enable secrets sync feature</A.Title>
<A.Description>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.</A.Description>
<A.Button
@text="Enable"
@color="secondary"
{{on "click" (fn (mut this.showActivateSecretsSyncModal) true)}}
data-test-secrets-sync-opt-in-banner-enable
/>
</Hds::Alert>
{{/unless}}
{{! show error if call to activated endpoint fails }}
{{#if @isAdapterError}}
<MessageError @errorMessage={{@activatedFeatures.message}} />
{{/if}}
{{#if @destinations}}
<SyncHeader @title="Secrets Sync" />
<div class="tabs-container box is-bottomless is-marginless is-paddingless">
<nav class="tabs" aria-label="destination tabs">
<ul>
@@ -162,5 +174,39 @@
</OverviewCard>
</div>
{{else}}
<Secrets::LandingCta />
<Secrets::LandingCta @isActivated={{this.isActivated}} />
{{/if}}
{{#if this.showActivateSecretsSyncModal}}
<Hds::Modal @onClose={{fn (mut this.showActivateSecretsSyncModal) false}} data-test-secrets-sync-opt-in-modal as |M|>
<M.Header @icon="alert-triangle">
Enable secrets sync feature
</M.Header>
<M.Body>
<p class="has-bottom-margin-m">
Before using this feature, we want to make sure youve carefully read the document around the billing and client
count impact.
<DocLink @path="/vault/docs/sync">Docs here.</DocLink>
</p>
<Hds::Form::Checkbox::Field {{on "change" this.onDocsConfirmChange}} data-test-opt-in-check as |F|>
<F.Label>I've read the above linked document</F.Label>
</Hds::Form::Checkbox::Field>
</M.Body>
<M.Footer>
<Hds::ButtonSet>
<Hds::Button
data-test-opt-in-confirm
@text="Confirm"
disabled={{this.confirmDisabled}}
{{on "click" (perform this.onFeatureConfirm)}}
/>
<Hds::Button
data-test-save-opt-in-cancel
@text="Cancel"
@color="secondary"
{{on "click" (fn (mut this.showActivateSecretsSyncModal) false)}}
/>
</Hds::ButtonSet>
</M.Footer>
</Hds::Modal>
{{/if}}

View File

@@ -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<SyncDestinationModel>;
totalAssociations: number;
totalVaultSecrets: number;
activatedFeatures: Array<string>;
isAdapterError: boolean;
}
export default class SyncSecretsDestinationsPageComponent extends Component<Args> {
@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<Args
}
}
get isActivated() {
if (this.args.isAdapterError) {
return false;
}
return this.args.activatedFeatures.includes('secrets-sync');
}
fetchAssociationsForDestinations = task(this, {}, async (page = 1) => {
try {
const total = page * this.pageSize;
@@ -51,4 +66,23 @@ export default class SyncSecretsDestinationsPageComponent extends Component<Args
this.destinationMetrics = [];
}
});
@action
onDocsConfirmChange(event: HTMLElementEvent<HTMLInputElement>) {
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)}`);
}
}
}

View File

@@ -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<string>;
unactivated: Array<string>;
};
}
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<string> | AdapterError }) {
if (!model.activatedFeatures) {
this.router.transitionTo('vault.cluster.sync.secrets.overview');
}
}
}

View File

@@ -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<string> | AdapterError;
};
return hash({
destinations: this.store.query('sync/destination', {}).catch(() => []),
associations: this.store
.adapterFor('sync/association')
.queryAll()
.catch(() => []),
activatedFeatures,
});
}
}

View File

@@ -6,4 +6,6 @@
<Secrets::Page::Overview
@destinations={{this.model.destinations}}
@totalVaultSecrets={{this.model.associations.total_secrets}}
@activatedFeatures={{this.model.activatedFeatures}}
@isAdapterError={{this.model.activatedFeatures.isAdapterError}}
/>

View File

@@ -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`;

View File

@@ -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();

View File

@@ -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]',

View File

@@ -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`
<Secrets::LandingCta @isActivated={{true}}/>
`,
{ owner: this.engine }
);
});
test('it should render promotional copy for community version', async function (assert) {
await render(
hbs`
<Secrets::LandingCta />
`,
{ 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`
<Secrets::LandingCta />
`,
{ 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');
});
});

View File

@@ -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`<Secrets::Page::Overview @destinations={{this.destinations}} @totalVaultSecrets={{7}} />`,
{
owner: this.engine,
}
);
this.renderComponent = () =>
render(
hbs`<Secrets::Page::Overview @destinations={{this.destinations}} @totalVaultSecrets={{7}} @activatedFeatures={{this.activatedFeatures}} @isAdapterError={{false}} />`,
{
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 = [
{