KV 2 Toolbar delete redesign (#11530)

* initial setup, modify toolbar header

* footer buttons setup

* setup first delete version delete method

* clean up

* handle destory all versions

* handle undelete

* conditional for modal and undelete

* remove delete from version area

* modelForData in permissions

* setup for soft delete and modify adpater to allow DELETE in additon to POST

* dropdown for soft delete

* stuck

* handle all soft deletes

* conditional for destroy all versions

* remove old functionality from secret-version-menu

* glimmerize secret-version-menu

* Updated secret version menu and version history

* Updated icons and columns in version history

* create new component

* clean up

* glimmerize secret delete menu

* fix undelete

* Fixed radio labels in version delete menu

* handle v1 delete

* refining

* handle errors with flash messages

* add changelog

* fix test

* add to test

* amend test

* address PR comments

* whoopies

* add urlEncoding

Co-authored-by: Arnav Palnitkar <arnav@hashicorp.com>
This commit is contained in:
Angel Garbarino 2021-05-19 10:43:55 -06:00 committed by GitHub
parent 6b8d7fe2e6
commit 8f5d62139c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 520 additions and 245 deletions

3
changelog/11530.txt Normal file
View File

@ -0,0 +1,3 @@
```release-note:improvement
ui: Redesign of KV 2 Delete toolbar.
```

View File

@ -70,14 +70,27 @@ export default ApplicationAdapter.extend({
v2DeleteOperation(store, id, deleteType = 'delete') { v2DeleteOperation(store, id, deleteType = 'delete') {
let [backend, path, version] = JSON.parse(id); let [backend, path, version] = JSON.parse(id);
// deleteType should be 'delete', 'destroy', 'undelete', 'delete-latest-version', 'destroy-version'
// deleteType should be 'delete', 'destroy', 'undelete' if ((!version && deleteType === 'delete') || deleteType === 'delete-latest-version') {
return this.ajax(this._url(backend, path, deleteType), 'POST', { data: { versions: [version] } }).then( return this.ajax(this._url(backend, path, 'data'), 'DELETE')
() => { .then(() => {
let model = store.peekRecord('secret-v2-version', id); let model = store.peekRecord('secret-v2-version', id);
return model && model.rollbackAttributes() && model.reload(); return model && model.rollbackAttributes() && model.reload();
} })
); .catch(e => {
return e;
});
} else {
return this.ajax(this._url(backend, path, deleteType), 'POST', { data: { versions: [version] } })
.then(() => {
let model = store.peekRecord('secret-v2-version', id);
// potential that model.reload() is never called.
return model && model.rollbackAttributes() && model.reload();
})
.catch(e => {
return e;
});
}
}, },
handleResponse(status, headers, payload, requestData) { handleResponse(status, headers, payload, requestData) {

View File

@ -0,0 +1,148 @@
import Ember from 'ember';
import { inject as service } from '@ember/service';
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { alias } from '@ember/object/computed';
import { maybeQueryRecord } from 'vault/macros/maybe-query-record';
const getErrorMessage = errors => {
let errorMessage = errors?.join('. ') || 'Something went wrong. Check the Vault logs for more information.';
return errorMessage;
};
export default class SecretDeleteMenu extends Component {
@service store;
@service router;
@service flashMessages;
@tracked showDeleteModal = false;
@maybeQueryRecord(
'capabilities',
context => {
if (!context.args.model) {
return;
}
let backend = context.args.model.backend;
let id = context.args.model.id;
let path = context.args.isV2
? `${encodeURIComponent(backend)}/data/${encodeURIComponent(id)}`
: `${encodeURIComponent(backend)}/${encodeURIComponent(id)}`;
return {
id: path,
};
},
'isV2',
'model',
'model.id',
'mode'
)
updatePath;
@alias('updatePath.canDelete') canDelete;
@alias('updatePath.canUpdate') canUpdate;
@maybeQueryRecord(
'capabilities',
context => {
if (!context.args || !context.args.modelForData || !context.args.modelForData.id) return;
let [backend, id] = JSON.parse(context.args.modelForData.id);
return {
id: `${encodeURIComponent(backend)}/delete/${encodeURIComponent(id)}`,
};
},
'model.id'
)
deleteVersionPath;
@alias('deleteVersionPath.canUpdate') canDeleteAnyVersion;
@maybeQueryRecord(
'capabilities',
context => {
if (!context.args || !context.args.modelForData || !context.args.modelForData.id) return;
let [backend, id] = JSON.parse(context.args.modelForData.id);
return {
id: `${encodeURIComponent(backend)}/undelete/${encodeURIComponent(id)}`,
};
},
'model.id'
)
undeleteVersionPath;
@alias('undeleteVersionPath.canUpdate') canUndeleteVersion;
@maybeQueryRecord(
'capabilities',
context => {
if (!context.args || !context.args.modelForData || !context.args.modelForData.id) return;
let [backend, id] = JSON.parse(context.args.modelForData.id);
return {
id: `${encodeURIComponent(backend)}/destroy/${encodeURIComponent(id)}`,
};
},
'model.id'
)
destroyVersionPath;
@alias('destroyVersionPath.canUpdate') canDestroyVersion;
@maybeQueryRecord(
'capabilities',
context => {
if (!context.args.model || !context.args.model.engine || !context.args.model.id) return;
let backend = context.args.model.engine.id;
let id = context.args.model.id;
return {
id: `${encodeURIComponent(backend)}/metadata/${encodeURIComponent(id)}`,
};
},
'model',
'model.id',
'mode'
)
v2UpdatePath;
@alias('v2UpdatePath.canDelete') canDestroyAllVersions;
get isLatestVersion() {
let { model } = this.args;
if (!model) return false;
let latestVersion = model.currentVersion;
let selectedVersion = model.selectedVersion.version;
if (latestVersion !== selectedVersion) {
return false;
}
return true;
}
@action
handleDelete(deleteType) {
// deleteType should be 'delete', 'destroy', 'undelete', 'delete-latest-version', 'destroy-all-versions'
if (!deleteType) {
return;
}
if (deleteType === 'destroy-all-versions' || deleteType === 'v1') {
let { id } = this.args.model;
this.args.model.destroyRecord().then(() => {
this.args.navToNearestAncestor.perform(id);
});
} else {
return this.store
.adapterFor('secret-v2-version')
.v2DeleteOperation(this.store, this.args.modelForData.id, deleteType)
.then(resp => {
if (Ember.testing) {
return;
}
if (!resp) {
this.showDeleteModal = false;
this.args.refresh();
return;
}
if (resp.isAdapterError) {
const errorMessage = getErrorMessage(resp.errors);
this.flashMessages.danger(errorMessage);
} else {
// not likely to ever get to this situation, but adding just in case.
location.reload();
}
});
}
}
}

View File

@ -112,7 +112,7 @@ export default Component.extend(FocusOnInsertMixin, WithNavToNearestAncestor, {
'model.id', 'model.id',
'mode' 'mode'
), ),
canDelete: alias('model.canDelete'), canDelete: alias('updatePath.canDelete'),
canEdit: alias('updatePath.canUpdate'), canEdit: alias('updatePath.canUpdate'),
v2UpdatePath: maybeQueryRecord( v2UpdatePath: maybeQueryRecord(

View File

@ -1,60 +1,5 @@
import { maybeQueryRecord } from 'vault/macros/maybe-query-record'; import Component from '@glimmer/component';
import Component from '@ember/component';
import { inject as service } from '@ember/service';
import { alias, or } from '@ember/object/computed';
export default Component.extend({ export default class SecretVersionMenu extends Component {
tagName: '', onRefresh() {}
store: service(), }
version: null,
useDefaultTrigger: false,
onRefresh() {},
deleteVersionPath: maybeQueryRecord(
'capabilities',
context => {
let [backend, id] = JSON.parse(context.version.id);
return {
id: `${backend}/delete/${id}`,
};
},
'version.id'
),
canDeleteVersion: alias('deleteVersionPath.canUpdate'),
destroyVersionPath: maybeQueryRecord(
'capabilities',
context => {
let [backend, id] = JSON.parse(context.version.id);
return {
id: `${backend}/destroy/${id}`,
};
},
'version.id'
),
canDestroyVersion: alias('destroyVersionPath.canUpdate'),
undeleteVersionPath: maybeQueryRecord(
'capabilities',
context => {
let [backend, id] = JSON.parse(context.version.id);
return {
id: `${backend}/undelete/${id}`,
};
},
'version.id'
),
canUndeleteVersion: alias('undeleteVersionPath.canUpdate'),
isFetchingVersionCapabilities: or(
'deleteVersionPath.isPending',
'destroyVersionPath.isPending',
'undeleteVersionPath.isPending'
),
actions: {
deleteVersion(deleteType = 'destroy') {
return this.store
.adapterFor('secret-v2-version')
.v2DeleteOperation(this.store, this.version.id, deleteType)
.then(this.onRefresh);
},
},
});

View File

@ -66,7 +66,6 @@
height: auto; height: auto;
line-height: 1rem; line-height: 1rem;
min-width: $spacing-l; min-width: $spacing-l;
z-index: 100;
border: 0; border: 0;
box-shadow: none; box-shadow: none;
color: $grey-light; color: $grey-light;

View File

@ -76,3 +76,17 @@ pre {
background: #f7f8fa; background: #f7f8fa;
border-top: 1px solid #bac1cc; border-top: 1px solid #bac1cc;
} }
.modal-radio-button {
display: flex;
align-items: baseline;
margin-bottom: $spacing-xs;
input {
top: 2px;
}
.helper-text {
margin-left: 10px;
}
}

View File

@ -0,0 +1,131 @@
{{#unless @isV2}}
{{#if this.canDelete}}
<ConfirmAction
@buttonClasses="toolbar-link"
@confirmTitle="Delete secret?"
@confirmMessage="You will not be able to recover this secret data later."
@onConfirmAction={{action "handleDelete" "v1"}}
data-test-secret-v1-delete="true"
>
Delete
</ConfirmAction>
{{/if}}
{{else}}
{{#if (and this.canUndeleteVersion @modelForData.deleted)}}
<button
type="button"
class="toolbar-link"
onclick={{action this.handleDelete "undelete"}}
data-test-secret-undelete
>
Undelete
</button>
{{/if}}
{{#if (and (not @modelForData.deleted) (not @modelForData.destroyed)) }}
{{#if (or this.canDestroyVersion this.canDestroyAllVersions)}}
<button
type="button"
class="toolbar-link"
{{on "click" (fn (mut this.showDeleteModal false))}}
data-test-delete-open-modal
>
{{if (and (not @modelForData.deleted) (not @modelForData.destroyed)) "Delete" "Destroy"}}
</button>
<div class="toolbar-separator"/>
{{else}}
{{#if (or this.canDeleteAnyVersion (and this.isLatestVersion this.canDelete))}}
<ConfirmAction
@buttonClasses="toolbar-link"
@confirmTitle="Delete"
@confirmMessage="Deleting this secret removes read access, but the underlying data will not be removed and can be undeleted later."
@onConfirmAction={{action "handleDelete" (if canDeleteAnyVersion "delete" "delete-latest-version") }}
data-test-secret-v2-delete="true"
>
Delete
</ConfirmAction>
{{/if}}
{{/if}}
{{/if}}
<Modal
@title="Delete Secret?"
@onClose={{action (mut this.showDeleteModal) false}}
@isActive={{this.showDeleteModal}}
@type="warning"
@showCloseButton={{true}}
>
<section class="modal-card-body">
<p class="has-bottom-margin-s">There are three ways to delete or destroy the <strong>{{@model.id}}</strong> secret. Each is different, so be sure to read the below carefully.</p>
<p class="has-bottom-margin-s"><strong>How would you like to proceed?</strong></p>
{{#unless @modelForData.destroyed}}
{{#unless @modelForData.deleted}}
{{#if this.canDelete}}
<div class="modal-radio-button" data-test-delete-modal="delete-version">
<RadioButton
@value="delete"
@radioClass="radio"
@groupValue={{this.deleteType}}
@changed={{action (mut this.deleteType)}}
@name="setup-deleteType"
@radioId="delete-version"
/>
<div class="helper-text">
<label for="delete-version" data-test-replication-type-select="delete-version"><strong>Delete this version</strong></label>
<p>This deletes Version {{@modelForData.version}} of the secret. It can be un-deleted later.</p>
</div>
</div>
{{/if}}
{{/unless}}
{{#if this.canDestroyVersion}}
<div class="modal-radio-button" data-test-delete-modal="destroy-version">
<RadioButton
@value="destroy"
@radioClass="radio"
@groupValue={{this.deleteType}}
@changed={{action (mut this.deleteType)}}
@name="setup-deleteType"
@radioId="destroy-version"
/>
<div class="helper-text">
<label for="destroy-version" data-test-replication-type-select="destroy-version"><strong>Destroy this version</strong></label>
<p>Version {{@modelForData.version}} is permanently destroyed and cannot be read or recovered later.</p>
</div>
</div>
{{/if}}
{{/unless}}
{{#if this.canDestroyAllVersions}}
<div class="modal-radio-button" data-test-delete-modal="destroy-all-versions">
<RadioButton
@value="destroy-all-versions"
@radioClass="radio"
@groupValue={{this.deleteType}}
@changed={{action (mut this.deleteType)}}
@name="setup-deleteType"
@radioId="destroy-all-versions"
/>
<div class="helper-text">
<label for="destroy-all-versions" data-test-replication-type-select="destroy-all-versions"><strong>Destroy all versions</strong></label>
<p>All secret versions and metadata are permanently destroyed and cannot be read or recovered later.</p>
</div>
</div>
{{/if}}
</section>
<footer class="modal-card-foot modal-card-foot-outlined">
<button
type="button"
class="button has-text-danger"
{{on "click" (fn this.handleDelete this.deleteType (action (mut this.showDeleteModal) false))}}
disabled={{if this.deleteType false true}}
>
Delete
</button>
<button
type="button"
class="button is-secondary"
{{on "click" (action (mut this.showDeleteModal) false)}}
>
Cancel
</button>
</footer>
</Modal>
{{/unless}}

View File

@ -33,74 +33,15 @@
</ToolbarFilters> </ToolbarFilters>
{{/unless}} {{/unless}}
<ToolbarActions> <ToolbarActions>
{{#if (and (eq @mode "show") this.isV2 (not @model.failedServerRead))}} {{#if (eq mode 'show')}}
<SecretVersionMenu <SecretDeleteMenu
@version={{this.modelForData}} @modelForData={{this.modelForData}}
@onRefresh={{action 'refresh'}} @model={{this.model}}
@navToNearestAncestor={{this.navToNearestAncestor}}
@isV2={{isV2}}
@refresh={{action 'refresh'}}
/> />
<BasicDropdown
@class="popup-menu"
@horizontalPosition="auto-right"
@verticalPosition="below"
as |D|
>
<D.trigger
data-test-popup-menu-trigger="history"
@class={{concat "popup-menu-trigger toolbar-link" (if D.isOpen " is-active")}}
@tagName="button"
>
History <Chevron @direction="down" @isButton={{true}} />
</D.trigger>
<D.content @class="popup-menu-content ">
<nav class="box menu">
<ul class="menu-list">
<li class="action">
<SecretLink
@data-test-version-history
@mode="versions"
@secret={{this.model.id}}
@class="has-text-black has-text-weight-semibold has-bottom-shadow"
@onLinkClick={{action D.actions.close}}
>
View version history
</SecretLink>
</li>
</ul>
<h5 class="list-header">Versions</h5>
<ul class="menu-list">
{{#each (reverse this.model.versions) as |secretVersion|}}
<li class="action">
<LinkTo class="link" @params={{array (query-params version=secretVersion.version)}} @invokeAction={{action D.actions.close}} >
Version {{secretVersion.version}}
{{#if (and (eq secretVersion.version this.model.currentVersion) (not secretVersion.deleted))}}
<Icon @glyph="check-circle-outline" class="has-text-success is-pulled-right" />
{{else if secretVersion.deleted}}
<Icon @glyph="cancel-square-outline" class="has-text-grey is-pulled-right" />
{{/if}}
</LinkTo>
</li>
{{/each}}
</ul>
</nav>
</D.content>
</BasicDropdown>
<div class="toolbar-separator"/>
{{/if}} {{/if}}
{{#if (and (eq mode 'show') canDelete)}}
<ConfirmAction
@buttonClasses="toolbar-link"
@onConfirmAction={{action "deleteKey"}}
@confirmMessage={{if isV2
(concat "This will permanently delete all versions of this secret.")
(concat "You will not be able to recover this secret data later.")
}}
data-test-secret-delete="true"
>
Delete secret
</ConfirmAction>
{{/if}}
{{#if (and (eq mode 'show') (or canEditV2Secret canEdit))}} {{#if (and (eq mode 'show') (or canEditV2Secret canEdit))}}
{{#let (concat 'vault.cluster.secrets.backend.' (if (eq mode 'show') 'edit' 'show')) as |targetRoute|}} {{#let (concat 'vault.cluster.secrets.backend.' (if (eq mode 'show') 'edit' 'show')) as |targetRoute|}}
{{#unless (and isV2 (or isWriteWithoutRead modelForData.destroyed modelForData.deleted))}} {{#unless (and isV2 (or isWriteWithoutRead modelForData.destroyed modelForData.deleted))}}
@ -116,7 +57,7 @@
@class={{concat "toolbar-link" (if D.isOpen " is-active")}} @class={{concat "toolbar-link" (if D.isOpen " is-active")}}
@tagName="button" @tagName="button"
> >
Copy secret Copy
<Chevron @direction="down" @isButton={{true}} /> <Chevron @direction="down" @isButton={{true}} />
</D.trigger> </D.trigger>
<D.content @class="popup-menu-content is-wide"> <D.content @class="popup-menu-content is-wide">
@ -159,7 +100,19 @@
</D.content> </D.content>
</BasicDropdown> </BasicDropdown>
{{/unless}} {{/unless}}
{{/let}}
{{/if}}
{{#if (and (eq @mode "show") this.isV2 (not @model.failedServerRead))}}
<SecretVersionMenu
@version={{this.modelForData}}
@onRefresh={{action 'refresh'}}
@model={{this.model}}
/>
{{/if}}
{{#if (and (eq mode 'show') (or canEditV2Secret canEdit))}}
{{#let (concat 'vault.cluster.secrets.backend.' (if (eq mode 'show') 'edit' 'show')) as |targetRoute|}}
{{#if isV2}} {{#if isV2}}
<ToolbarLink <ToolbarLink
@params={{array targetRoute model.id (query-params version=this.modelForData.version)}} @params={{array targetRoute model.id (query-params version=this.modelForData.version)}}

View File

@ -5,16 +5,12 @@
as |D| as |D|
> >
<D.trigger <D.trigger
data-test-popup-menu-trigger="true" data-test-popup-menu-trigger="version"
@class={{concat "toolbar-link" (if D.isOpen " is-active")}} @class={{concat "toolbar-link" (if D.isOpen " is-active")}}
@tagName="button" @tagName="button"
> >
{{#if useDefaultTrigger}} Version {{@version.version}}
<Icon aria-label="More options" @glyph="more-horizontal" class="has-text-black auto-width" /> <Chevron @direction="down" @isButton={{true}} />
{{else}}
Version {{this.version.version}}
<Chevron @direction="down" @isButton={{true}} />
{{/if}}
</D.trigger> </D.trigger>
<D.content @class="popup-menu-content "> <D.content @class="popup-menu-content ">
<nav class="box menu"> <nav class="box menu">
@ -22,66 +18,31 @@
{{#if (has-block)}} {{#if (has-block)}}
{{yield}} {{yield}}
{{/if}} {{/if}}
{{#if this.version.destroyed}} {{#each (reverse @model.versions) as |secretVersion|}}
<li class="action has-text-grey"> <li class="action">
<button type="button" class="link" disabled > <LinkTo class="link" @params={{array (query-params version=secretVersion.version)}} @invokeAction={{action D.actions.close}} >
The data for {{this.version.path}} version {{this.version.version}} has been destroyed. Version {{secretVersion.version}}
</button> {{#if (and (eq secretVersion.version @model.currentVersion) (not secretVersion.destroyed) (not secretVersion.deleted))}}
</li> <Icon @glyph="check-circle-outline" class="has-text-success is-pulled-right" />
{{else}} {{else if secretVersion.destroyed}}
{{#if isFetchingVersionCapabilities}} <Icon @glyph="cancel-square-fill" class="has-text-danger is-pulled-right" />
<li class="action"> {{else if secretVersion.deleted}}
<button disabled type="button" class="link button is-loading is-transparent"> <Icon @glyph="cancel-square-fill" class="has-text-grey is-pulled-right" />
loading
</button>
</li>
{{else}}
<li class="action">
{{#if this.version.deleted}}
{{#if canUndeleteVersion}}
<button type="button" class="link" {{action (queue (action D.actions.close) (action "deleteVersion" "undelete"))}}>
Undelete version
</button>
{{else}}
<button type="button" class="link" disabled >
The data for {{this.version.path}} version {{this.version.version}} has been deleted. You do not have the permisssion to undelete it.
</button>
{{/if}}
{{else if canDeleteVersion}}
<ConfirmAction
@buttonClasses="link is-destroy"
@confirmTitle="Delete version?"
@confirmMessage="This version will no longer be able to be read, but the underlying data will not be removed and can still be undeleted."
@onConfirmAction={{action (queue (action D.actions.close) (action "deleteVersion" "delete"))}}
data-test-secret-v2-delete="true"
>
Delete version
</ConfirmAction>
{{else}}
<button type="button" class="link" disabled >
You do not have the permissions to delete the data for this secret.
</button>
{{/if}} {{/if}}
</li> </LinkTo>
{{#if canDestroyVersion}} </li>
<li class="action"> {{/each}}
<ConfirmAction <li class="action">
@buttonClasses="link is-destroy" <SecretLink
@confirmTitle="Permanently delete?" @data-test-version-history
@confirmMessage="This version will no longer be able to be read, and cannot be undeleted." @mode="versions"
@onConfirmAction={{action (queue (action D.actions.close) (action "deleteVersion" "destroy"))}} @secret={{@model.id}}
data-test-secret-v2-destroy="true" @class="has-text-black has-text-weight-semibold has-bottom-shadow"
> @onLinkClick={{action D.actions.close}}
Permanently destroy version >
</ConfirmAction> View version history
</li> </SecretLink>
{{else}} </li>
<button type="button" class="link" disabled >
You do not have the permissions to destroy the data for this secret.
</button>
{{/if}}
{{/if}}
{{/if}}
</ul> </ul>
</nav> </nav>
</D.content> </D.content>

View File

@ -1,11 +1,16 @@
<PageHeader as |p|> <PageHeader as |p|>
<p.top> <p.top>
<KeyValueHeader @baseKey={{hash id=model.id}} @path="vault.cluster.secrets.backend.list" @mode="show" @root={{hash <KeyValueHeader
@baseKey={{hash id=model.id}}
@path="vault.cluster.secrets.backend.list"
@mode="show"
@root={{hash
label=model.engineId label=model.engineId
text=model.engineId text=model.engineId
path="vault.cluster.secrets.backend.list-root" path="vault.cluster.secrets.backend.list-root"
model=model.engineId model=model.engineId }}
}} @showCurrent={{true}} /> @showCurrent={{true}}
/>
</p.top> </p.top>
<p.levelLeft> <p.levelLeft>
<h1 class="title is-3"> <h1 class="title is-3">
@ -17,52 +22,75 @@
<ListItem @hasMenu={{false}} @linkParams={{array 'vault.cluster.secrets.backend.show' model.id (query-params version=list.item.version) }} as |Item|> <ListItem @hasMenu={{false}} @linkParams={{array 'vault.cluster.secrets.backend.show' model.id (query-params version=list.item.version) }} as |Item|>
<Item.content> <Item.content>
<div class="columns is-flex-1"> <div class="columns is-flex-1">
<div class="column is-one-third"> <div>
<Icon @glyph="file-outline" class="has-text-grey" />Version {{list.item.version}} <Icon @glyph="file-outline" class="has-text-grey" />
{{#if (eq list.item.version model.currentVersion)}} Version {{list.item.version}}
</div>
{{#if (eq list.item.version model.currentVersion)}}
<div class="column is-1">
<span class="has-text-success is-size-9"> <span class="has-text-success is-size-9">
<Icon @glyph="check-circle-outline" />Current <Icon @glyph="check-circle-fill" />Current
</span> </span>
{{/if}} </div>
</div> {{/if}}
<div class="column"> {{#if list.item.deleted}}
{{#if list.item.deleted}} <div class="column is-1">
<span class="has-text-grey is-size-8"> <span class="has-text-grey is-size-8">
<Icon @glyph="cancel-square-outline" />Deleted <Icon @glyph="cancel-square-fill" />Deleted
</span> </span>
{{/if}} </div>
{{#if list.item.destroyed}} {{/if}}
{{#if list.item.destroyed}}
<div class="column is-1">
<span class="has-text-danger is-size-8"> <span class="has-text-danger is-size-8">
<Icon @glyph="cancel-square-outline" />Destroyed <Icon @glyph="cancel-square-fill" />Destroyed
</span> </span>
{{/if}} </div>
</div> {{/if}}
</div> </div>
</Item.content> </Item.content>
<Item.menu> <Item.menu>
<SecretVersionMenu @version={{list.item}} @useDefaultTrigger={{true}}> <BasicDropdown
<li class="action"> @class="popup-menu"
<SecretLink @horizontalPosition="auto-right"
@data-test-version @verticalPosition="below"
@mode="show" as |D|
@secret={{model.id}} >
@class="has-text-black has-text-weight-semibold" <D.trigger
@queryParams={{query-params version=list.item.version}} data-test-popup-menu-trigger="true"
> @class={{concat "toolbar-link" (if D.isOpen " is-active")}}
View version {{list.item.version}} @tagName="button"
</SecretLink> >
</li> <Icon aria-label="More options" @glyph="more-horizontal" class="has-text-black auto-width" />
<li class="action"> </D.trigger>
<SecretLink <D.content @class="popup-menu-content ">
@mode="edit" <nav class="box menu">
@secret={{model.id}} <ul class="menu-list">
@class="has-text-black has-text-weight-semibold" <li class="action">
@queryParams={{query-params version=list.item.version}} <SecretLink
> @data-test-version
Create new version from {{list.item.version}} @mode="show"
</SecretLink> @secret={{model.id}}
</li> @class="has-text-black has-text-weight-semibold"
</SecretVersionMenu> @queryParams={{query-params version=list.item.version}}
>
View version {{list.item.version}}
</SecretLink>
</li>
<li class="action">
<SecretLink
@mode="edit"
@secret={{model.id}}
@class="has-text-black has-text-weight-semibold"
@queryParams={{query-params version=list.item.version}}
>
Create new version from {{list.item.version}}
</SecretLink>
</li>
</ul>
</nav>
</D.content>
</BasicDropdown>
</Item.menu> </Item.menu>
</ListItem> </ListItem>
</ListView> </ListView>

View File

@ -144,7 +144,7 @@ module('Acceptance | secrets/secret/create', function(hooks) {
.submit(); .submit();
await listPage.create(); await listPage.create();
await editPage.createSecret(secretPath, 'foo', 'bar'); await editPage.createSecret(secretPath, 'foo', 'bar');
await showPage.deleteSecret(); await showPage.deleteSecretV1();
assert.equal( assert.equal(
currentRouteName(), currentRouteName(),
'vault.cluster.secrets.backend.list-root', 'vault.cluster.secrets.backend.list-root',
@ -251,6 +251,78 @@ module('Acceptance | secrets/secret/create', function(hooks) {
assert.ok(showPage.editIsPresent, 'shows the edit button'); assert.ok(showPage.editIsPresent, 'shows the edit button');
}); });
test('version 2 with policy with destroy capabilities shows modal', async function(assert) {
let backend = 'kv-v2';
const V2_POLICY = `
path "kv-v2/destroy/*" {
capabilities = ["update"]
}
path "kv-v2/metadata/*" {
capabilities = ["list", "update", "delete"]
}
path "kv-v2/data/secret" {
capabilities = ["create", "read", "update"]
}
`;
await consoleComponent.runCommands([
`write sys/mounts/${backend} type=kv options=version=2`,
`write sys/policies/acl/kv-v2-degrade policy=${btoa(V2_POLICY)}`,
// delete any kv previously written here so that tests can be re-run
'delete kv-v2/metadata/secret',
'write -field=client_token auth/token/create policies=kv-v2-degrade',
]);
let userToken = consoleComponent.lastLogOutput;
await logout.visit();
await authPage.login(userToken);
await writeSecret(backend, 'secret', 'foo', 'bar');
await click('[data-test-delete-open-modal]');
await settled();
assert.dom('[data-test-delete-modal="destroy-version"]').exists('destroy this version option shows');
assert.dom('[data-test-delete-modal="destroy-all-versions"]').exists('destroy all versions option shows');
assert.dom('[data-test-delete-modal="delete-version"]').doesNotExist('delete version does not show');
});
test('version 2 with policy with only delete option does not show modal and undelete is an option', async function(assert) {
let backend = 'kv-v2';
const V2_POLICY = `
path "kv-v2/delete/*" {
capabilities = ["update"]
}
path "kv-v2/undelete/*" {
capabilities = ["update"]
}
path "kv-v2/metadata/*" {
capabilities = ["list","read","create","update"]
}
path "kv-v2/data/secret" {
capabilities = ["create", "read"]
}
`;
await consoleComponent.runCommands([
`write sys/mounts/${backend} type=kv options=version=2`,
`write sys/policies/acl/kv-v2-degrade policy=${btoa(V2_POLICY)}`,
// delete any kv previously written here so that tests can be re-run
'delete kv-v2/metadata/secret',
'write -field=client_token auth/token/create policies=kv-v2-degrade',
]);
let userToken = consoleComponent.lastLogOutput;
await logout.visit();
await authPage.login(userToken);
await writeSecret(backend, 'secret', 'foo', 'bar');
assert.dom('[data-test-delete-open-modal]').doesNotExist('delete version does not show');
assert.dom('[data-test-secret-v2-delete="true"]').exists('drop down delete shows');
await showPage.deleteSecretV2();
// unable to reload page in test scenario so going to list and back to secret to confirm deletion
let url = `/vault/secrets/${backend}/list`;
await visit(url);
await click('[data-test-secret-link="secret"]');
assert.dom('[data-test-component="empty-state"]').exists('secret has been deleted');
assert.dom('[data-test-secret-undelete]').exists('undelete button shows');
});
test('paths are properly encoded', async function(assert) { test('paths are properly encoded', async function(assert) {
let backend = 'kv'; let backend = 'kv';
let paths = [ let paths = [
@ -305,7 +377,7 @@ module('Acceptance | secrets/secret/create', function(hooks) {
await listPage.create(); await listPage.create();
await editPage.createSecret(secretPath, 'foo', 'bar'); await editPage.createSecret(secretPath, 'foo', 'bar');
await settled(); await settled();
await click('[data-test-popup-menu-trigger="history"]'); await click('[data-test-popup-menu-trigger="version"]');
await settled(); await settled();
await click('[data-test-version-history]'); await click('[data-test-version-history]');
await settled(); await settled();

View File

@ -8,6 +8,8 @@ export default create({
text: text(), text: text(),
}), }),
deleteBtn: clickable('[data-test-secret-delete] button'), deleteBtn: clickable('[data-test-secret-delete] button'),
deleteBtnV1: clickable('[data-test-secret-v1-delete="true"] button'),
deleteBtnV2: clickable('[data-test-secret-v2-delete="true"] button'),
confirmBtn: clickable('[data-test-confirm-button]'), confirmBtn: clickable('[data-test-confirm-button]'),
rows: collection('data-test-row-label'), rows: collection('data-test-row-label'),
toggleJSON: clickable('[data-test-secret-json-toggle]'), toggleJSON: clickable('[data-test-secret-json-toggle]'),
@ -22,4 +24,10 @@ export default create({
deleteSecret() { deleteSecret() {
return this.deleteBtn().confirmBtn(); return this.deleteBtn().confirmBtn();
}, },
deleteSecretV1() {
return this.deleteBtnV1().confirmBtn();
},
deleteSecretV2() {
return this.deleteBtnV2().confirmBtn();
},
}); });