mirror of
https://github.com/optim-enterprises-bv/vault.git
synced 2025-10-29 17:52:32 +00:00
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:
@@ -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>
|
||||
|
||||
@@ -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 you’ve 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}}
|
||||
@@ -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)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
45
ui/lib/sync/addon/routes/secrets.ts
Normal file
45
ui/lib/sync/addon/routes/secrets.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}}
|
||||
/>
|
||||
@@ -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`;
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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]',
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 = [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user