mirror of
				https://github.com/optim-enterprises-bv/vault.git
				synced 2025-11-01 02:57:59 +00:00 
			
		
		
		
	Secrets Sync UI: Add purge delete progress and error banner to destination header (#24761)
* add deletion in progress banner * update kv details banner to inline alert * add logic for purge error * add params to mirage * comment in purge_initiated_at for mirage * update flash message for deleting * add test for banner * transition to destination associations after delete * redirect to details after delete instead of list * remove attrs from serializer * update mirage handler to mock purge_initiated_at
This commit is contained in:
		| @@ -20,6 +20,9 @@ const validations = { | |||||||
| export default class SyncDestinationModel extends Model { | export default class SyncDestinationModel extends Model { | ||||||
|   @attr('string', { subText: 'Specifies the name for this destination.', editDisabled: true }) name; |   @attr('string', { subText: 'Specifies the name for this destination.', editDisabled: true }) name; | ||||||
|   @attr type; |   @attr type; | ||||||
|  |   // only present if delete action has been initiated | ||||||
|  |   @attr('string') purgeInitiatedAt; | ||||||
|  |   @attr('string') purgeError; | ||||||
|  |  | ||||||
|   // findDestination returns static attributes for each destination type |   // findDestination returns static attributes for each destination type | ||||||
|   get icon() { |   get icon() { | ||||||
|   | |||||||
| @@ -9,6 +9,8 @@ export default class SyncDestinationSerializer extends ApplicationSerializer { | |||||||
|   attrs = { |   attrs = { | ||||||
|     name: { serialize: false }, |     name: { serialize: false }, | ||||||
|     type: { serialize: false }, |     type: { serialize: false }, | ||||||
|  |     purgeInitiatedAt: { serialize: false }, | ||||||
|  |     purgeError: { serialize: false }, | ||||||
|   }; |   }; | ||||||
|  |  | ||||||
|   serialize(snapshot) { |   serialize(snapshot) { | ||||||
|   | |||||||
| @@ -6,7 +6,7 @@ | |||||||
| <KvPageHeader @breadcrumbs={{@breadcrumbs}} @pageTitle={{@path}}> | <KvPageHeader @breadcrumbs={{@breadcrumbs}} @pageTitle={{@path}}> | ||||||
|   <:syncDetails> |   <:syncDetails> | ||||||
|     {{#if this.syncStatus}} |     {{#if this.syncStatus}} | ||||||
|       <Hds::Alert data-test-sync-alert @type="page" @color="neutral" as |A|> |       <Hds::Alert data-test-sync-alert @type="inline" class="has-top-margin-s has-bottom-margin-m" @color="neutral" as |A|> | ||||||
|         <A.Title> |         <A.Title> | ||||||
|           This secret has been synced from Vault to |           This secret has been synced from Vault to | ||||||
|           {{pluralize this.syncStatus.length "destination"}}. Updates to this secret will automatically sync to its |           {{pluralize this.syncStatus.length "destination"}}. Updates to this secret will automatically sync to its | ||||||
|   | |||||||
| @@ -13,6 +13,35 @@ | |||||||
|   }} |   }} | ||||||
| /> | /> | ||||||
|  |  | ||||||
|  | {{#if @destination.purgeInitiatedAt}} | ||||||
|  |   <Hds::Alert | ||||||
|  |     data-test-delete-status-banner | ||||||
|  |     @type="inline" | ||||||
|  |     class="has-bottom-margin-m" | ||||||
|  |     @color={{if @destination.purgeError "critical" "neutral"}} | ||||||
|  |     @icon={{unless @destination.purgeError "loading-static"}} | ||||||
|  |     as |A| | ||||||
|  |   > | ||||||
|  |     {{#if @destination.purgeError}} | ||||||
|  |       <A.Title>Deletion failed</A.Title> | ||||||
|  |       <A.Description> | ||||||
|  |         There was a problem with the delete purge initiated at | ||||||
|  |         {{date-format @destination.purgeInitiatedAt "MMM dd, yyyy 'at' hh:mm:ss aaa"}}. | ||||||
|  |       </A.Description> | ||||||
|  |       <A.Description> | ||||||
|  |         {{@destination.purgeError}} | ||||||
|  |       </A.Description> | ||||||
|  |     {{else}} | ||||||
|  |       <A.Title>Deletion in progress</A.Title> | ||||||
|  |       <A.Description> | ||||||
|  |         Purge initiated on | ||||||
|  |         {{date-format @destination.purgeInitiatedAt "MMM dd, yyyy 'at' hh:mm:ss aaa"}}. This process may take some time | ||||||
|  |         depending on how many secrets must be un-synced from this destination. | ||||||
|  |       </A.Description> | ||||||
|  |     {{/if}} | ||||||
|  |   </Hds::Alert> | ||||||
|  | {{/if}} | ||||||
|  |  | ||||||
| <div class="tabs-container box is-bottomless is-marginless is-paddingless"> | <div class="tabs-container box is-bottomless is-marginless is-paddingless"> | ||||||
|   <nav class="tabs" aria-label="destination tabs"> |   <nav class="tabs" aria-label="destination tabs"> | ||||||
|     <ul> |     <ul> | ||||||
|   | |||||||
| @@ -26,10 +26,14 @@ export default class DestinationsTabsToolbar extends Component<Args> { | |||||||
|   async deleteDestination() { |   async deleteDestination() { | ||||||
|     try { |     try { | ||||||
|       const { destination } = this.args; |       const { destination } = this.args; | ||||||
|       const message = `Successfully deleted destination ${destination.name}.`; |       const message = `Destination ${destination.name} has been queued for deletion.`; | ||||||
|       await destination.destroyRecord(); |       await destination.destroyRecord(); | ||||||
|       this.store.clearDataset('sync/destination'); |       this.store.clearDataset('sync/destination'); | ||||||
|       this.router.transitionTo('vault.cluster.sync.secrets.destinations'); |       this.router.transitionTo( | ||||||
|  |         'vault.cluster.sync.secrets.destinations.destination.secrets', | ||||||
|  |         destination.type, | ||||||
|  |         destination.name | ||||||
|  |       ); | ||||||
|       this.flashMessages.success(message); |       this.flashMessages.success(message); | ||||||
|     } catch (error) { |     } catch (error) { | ||||||
|       this.flashMessages.danger(`Error deleting destination \n ${errorMessage(error)}`); |       this.flashMessages.danger(`Error deleting destination \n ${errorMessage(error)}`); | ||||||
|   | |||||||
| @@ -93,11 +93,11 @@ export default class SyncSecretsDestinationsPageComponent extends Component<Args | |||||||
|   @action |   @action | ||||||
|   async onDelete(destination: SyncDestinationModel) { |   async onDelete(destination: SyncDestinationModel) { | ||||||
|     try { |     try { | ||||||
|       const { name } = destination; |       const { name, type } = destination; | ||||||
|       const message = `Successfully deleted destination ${name}.`; |       const message = `Destination ${name} has been queued for deletion.`; | ||||||
|       await destination.destroyRecord(); |       await destination.destroyRecord(); | ||||||
|       this.store.clearDataset('sync/destination'); |       this.store.clearDataset('sync/destination'); | ||||||
|       this.router.transitionTo('vault.cluster.sync.secrets.destinations'); |       this.router.transitionTo('vault.cluster.sync.secrets.destinations.destination.secrets', type, name); | ||||||
|       this.flashMessages.success(message); |       this.flashMessages.success(message); | ||||||
|     } catch (error) { |     } catch (error) { | ||||||
|       this.flashMessages.danger(`Error deleting destination \n ${errorMessage(error)}`); |       this.flashMessages.danger(`Error deleting destination \n ${errorMessage(error)}`); | ||||||
|   | |||||||
| @@ -45,6 +45,7 @@ export default class DestinationsCreateForm extends Component<Args> { | |||||||
|           title: `Edit ${name}`, |           title: `Edit ${name}`, | ||||||
|           breadcrumbs: [ |           breadcrumbs: [ | ||||||
|             { label: 'Secrets Sync', route: 'secrets.overview' }, |             { label: 'Secrets Sync', route: 'secrets.overview' }, | ||||||
|  |             { label: 'Destinations', route: 'secrets.destinations' }, | ||||||
|             { |             { | ||||||
|               label: 'Destination', |               label: 'Destination', | ||||||
|               route: 'secrets.destinations.destination.secrets', |               route: 'secrets.destinations.destination.secrets', | ||||||
|   | |||||||
| @@ -27,7 +27,6 @@ export default Factory.extend({ | |||||||
|     type: 'gcp-sm', |     type: 'gcp-sm', | ||||||
|     name: 'destination-gcp', |     name: 'destination-gcp', | ||||||
|     credentials: '*****', |     credentials: '*****', | ||||||
|     project_id: 'gcp-project-id', // TODO backend will add, doesn't exist yet |  | ||||||
|   }), |   }), | ||||||
|   gh: trait({ |   gh: trait({ | ||||||
|     type: 'gh', |     type: 'gh', | ||||||
|   | |||||||
| @@ -133,8 +133,22 @@ export default function (server) { | |||||||
|   }); |   }); | ||||||
|   server.delete(uri, (schema, req) => { |   server.delete(uri, (schema, req) => { | ||||||
|     const { type, name } = req.params; |     const { type, name } = req.params; | ||||||
|     schema.db.syncDestinations.remove({ type, name }); |     schema.db.syncDestinations.update( | ||||||
|     return new Response(204); |       { type, name }, | ||||||
|  |       // these parameters are added after a purge delete is initiated | ||||||
|  |       // if only `purge_initiated_at` exists the delete progress banner renders | ||||||
|  |       // if `purge_error` also has a value then delete failed banner renders | ||||||
|  |       { | ||||||
|  |         purge_initiated_at: '2024-01-09T16:54:28.463879-07:00', | ||||||
|  |         // WIP (backend hasn't added yet) update when we have a realistic error message) | ||||||
|  |         // purge_error: '1 error occurred: association could for some confusing reason not be un-synced!', | ||||||
|  |       } | ||||||
|  |     ); | ||||||
|  |     const record = schema.db.syncDestinations.findBy({ type, name }); | ||||||
|  |     return destinationResponse(record); | ||||||
|  |     // return the following instead to test immediate deletion | ||||||
|  |     // schema.db.syncDestinations.remove({ type, name }); | ||||||
|  |     // return new Response(204); | ||||||
|   }); |   }); | ||||||
|   // associations |   // associations | ||||||
|   server.get('/sys/sync/associations', (schema) => { |   server.get('/sys/sync/associations', (schema) => { | ||||||
|   | |||||||
| @@ -26,6 +26,7 @@ export const PAGE = { | |||||||
|     }, |     }, | ||||||
|   }, |   }, | ||||||
|   destinations: { |   destinations: { | ||||||
|  |     deleteBanner: '[data-test-delete-status-banner]', | ||||||
|     sync: { |     sync: { | ||||||
|       mountSelect: '[data-test-sync-mount-select]', |       mountSelect: '[data-test-sync-mount-select]', | ||||||
|       mountInput: '[data-test-sync-mount-input]', |       mountInput: '[data-test-sync-mount-input]', | ||||||
|   | |||||||
| @@ -9,7 +9,7 @@ import { setupEngine } from 'ember-engines/test-support'; | |||||||
| import { setupMirage } from 'ember-cli-mirage/test-support'; | import { setupMirage } from 'ember-cli-mirage/test-support'; | ||||||
| import { setupModels } from 'vault/tests/helpers/sync/setup-models'; | import { setupModels } from 'vault/tests/helpers/sync/setup-models'; | ||||||
| import hbs from 'htmlbars-inline-precompile'; | import hbs from 'htmlbars-inline-precompile'; | ||||||
| import { click, fillIn, render } from '@ember/test-helpers'; | import { click, fillIn, render, settled } from '@ember/test-helpers'; | ||||||
| import { allowAllCapabilitiesStub } from 'vault/tests/helpers/stubs'; | import { allowAllCapabilitiesStub } from 'vault/tests/helpers/stubs'; | ||||||
| import { PAGE } from 'vault/tests/helpers/sync/sync-selectors'; | import { PAGE } from 'vault/tests/helpers/sync/sync-selectors'; | ||||||
| import sinon from 'sinon'; | import sinon from 'sinon'; | ||||||
| @@ -60,7 +60,7 @@ module('Integration | Component | sync | Secrets::DestinationHeader', function ( | |||||||
|  |  | ||||||
|     assert.propEqual( |     assert.propEqual( | ||||||
|       transitionStub.lastCall.args, |       transitionStub.lastCall.args, | ||||||
|       ['vault.cluster.sync.secrets.destinations'], |       ['vault.cluster.sync.secrets.destinations.destination.secrets', 'aws-sm', 'us-west-1'], | ||||||
|       'Transition is triggered on delete success' |       'Transition is triggered on delete success' | ||||||
|     ); |     ); | ||||||
|     assert.propEqual( |     assert.propEqual( | ||||||
| @@ -69,4 +69,34 @@ module('Integration | Component | sync | Secrets::DestinationHeader', function ( | |||||||
|       'Store dataset is cleared on delete success' |       'Store dataset is cleared on delete success' | ||||||
|     ); |     ); | ||||||
|   }); |   }); | ||||||
|  |  | ||||||
|  |   test('it should render delete progress banner', async function (assert) { | ||||||
|  |     assert.expect(2); | ||||||
|  |     this.destination.set('purgeInitiatedAt', '2024-01-09T16:54:28.463879'); | ||||||
|  |     await settled(); | ||||||
|  |     assert | ||||||
|  |       .dom(PAGE.destinations.deleteBanner) | ||||||
|  |       .hasText( | ||||||
|  |         'Deletion in progress Purge initiated on Jan 09, 2024 at 04:54:28 pm. This process may take some time depending on how many secrets must be un-synced from this destination.' | ||||||
|  |       ); | ||||||
|  |     assert | ||||||
|  |       .dom(`${PAGE.destinations.deleteBanner} ${PAGE.icon('loading-static')}`) | ||||||
|  |       .exists('banner renders loading icon'); | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   test('it should render delete error banner', async function (assert) { | ||||||
|  |     assert.expect(2); | ||||||
|  |     this.destination.set('purgeInitiatedAt', '2024-01-09T16:54:28.463879'); | ||||||
|  |     this.destination.set('purgeError', 'oh no! a problem occurred!'); | ||||||
|  |     await settled(); | ||||||
|  |     assert | ||||||
|  |       .dom(PAGE.destinations.deleteBanner) | ||||||
|  |       .hasText( | ||||||
|  |         'Deletion failed There was a problem with the delete purge initiated at Jan 09, 2024 at 04:54:28 pm. oh no! a problem occurred!', | ||||||
|  |         'banner renders error message' | ||||||
|  |       ); | ||||||
|  |     assert | ||||||
|  |       .dom(`${PAGE.destinations.deleteBanner} ${PAGE.icon('alert-diamond')}`) | ||||||
|  |       .exists('banner renders critical icon'); | ||||||
|  |   }); | ||||||
| }); | }); | ||||||
|   | |||||||
| @@ -154,7 +154,7 @@ module('Integration | Component | sync | Page::Destinations', function (hooks) { | |||||||
|  |  | ||||||
|     assert.propEqual( |     assert.propEqual( | ||||||
|       this.transitionStub.lastCall.args, |       this.transitionStub.lastCall.args, | ||||||
|       ['vault.cluster.sync.secrets.destinations'], |       ['vault.cluster.sync.secrets.destinations.destination.secrets', 'aws-sm', 'destination-aws'], | ||||||
|       'Transition is triggered on delete success' |       'Transition is triggered on delete success' | ||||||
|     ); |     ); | ||||||
|     assert.propEqual( |     assert.propEqual( | ||||||
|   | |||||||
| @@ -58,7 +58,7 @@ module('Integration | Component | sync | Secrets::Page::Destinations::CreateAndE | |||||||
|     this.model = this.store.peekRecord(`sync/destinations/${type}`, id); |     this.model = this.store.peekRecord(`sync/destinations/${type}`, id); | ||||||
|  |  | ||||||
|     await this.renderFormComponent(); |     await this.renderFormComponent(); | ||||||
|     assert.dom(PAGE.breadcrumbs).hasText('Secrets Sync Destination Edit Destination'); |     assert.dom(PAGE.breadcrumbs).hasText('Secrets Sync Destinations Destination Edit Destination'); | ||||||
|     assert.dom('h2').hasText('Credentials', 'renders credentials section on edit'); |     assert.dom('h2').hasText('Credentials', 'renders credentials section on edit'); | ||||||
|     assert |     assert | ||||||
|       .dom('p.hds-foreground-faint') |       .dom('p.hds-foreground-faint') | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user
	 claire bontempo
					claire bontempo