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:
parent
6b8d7fe2e6
commit
8f5d62139c
3
changelog/11530.txt
Normal file
3
changelog/11530.txt
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
```release-note:improvement
|
||||||
|
ui: Redesign of KV 2 Delete toolbar.
|
||||||
|
```
|
|
@ -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) {
|
||||||
|
|
148
ui/app/components/secret-delete-menu.js
Normal file
148
ui/app/components/secret-delete-menu.js
Normal 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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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(
|
||||||
|
|
|
@ -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);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
131
ui/app/templates/components/secret-delete-menu.hbs
Normal file
131
ui/app/templates/components/secret-delete-menu.hbs
Normal 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}}
|
|
@ -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}}
|
{{/if}}
|
||||||
</LinkTo>
|
|
||||||
</li>
|
|
||||||
{{/each}}
|
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
</D.content>
|
|
||||||
</BasicDropdown>
|
|
||||||
<div class="toolbar-separator"/>
|
|
||||||
{{/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)}}
|
||||||
|
|
|
@ -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" />
|
|
||||||
{{else}}
|
|
||||||
Version {{this.version.version}}
|
|
||||||
<Chevron @direction="down" @isButton={{true}} />
|
<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">
|
|
||||||
<button type="button" class="link" disabled >
|
|
||||||
The data for {{this.version.path}} version {{this.version.version}} has been destroyed.
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
{{else}}
|
|
||||||
{{#if isFetchingVersionCapabilities}}
|
|
||||||
<li class="action">
|
<li class="action">
|
||||||
<button disabled type="button" class="link button is-loading is-transparent">
|
<LinkTo class="link" @params={{array (query-params version=secretVersion.version)}} @invokeAction={{action D.actions.close}} >
|
||||||
loading
|
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}}
|
||||||
<li class="action">
|
<Icon @glyph="cancel-square-fill" class="has-text-danger is-pulled-right" />
|
||||||
{{#if this.version.deleted}}
|
{{else if secretVersion.deleted}}
|
||||||
{{#if canUndeleteVersion}}
|
<Icon @glyph="cancel-square-fill" class="has-text-grey is-pulled-right" />
|
||||||
<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}}
|
{{/if}}
|
||||||
{{else if canDeleteVersion}}
|
</LinkTo>
|
||||||
<ConfirmAction
|
</li>
|
||||||
@buttonClasses="link is-destroy"
|
{{/each}}
|
||||||
@confirmTitle="Delete version?"
|
<li class="action">
|
||||||
@confirmMessage="This version will no longer be able to be read, but the underlying data will not be removed and can still be undeleted."
|
<SecretLink
|
||||||
@onConfirmAction={{action (queue (action D.actions.close) (action "deleteVersion" "delete"))}}
|
@data-test-version-history
|
||||||
data-test-secret-v2-delete="true"
|
@mode="versions"
|
||||||
|
@secret={{@model.id}}
|
||||||
|
@class="has-text-black has-text-weight-semibold has-bottom-shadow"
|
||||||
|
@onLinkClick={{action D.actions.close}}
|
||||||
>
|
>
|
||||||
Delete version
|
View version history
|
||||||
</ConfirmAction>
|
</SecretLink>
|
||||||
{{else}}
|
|
||||||
<button type="button" class="link" disabled >
|
|
||||||
You do not have the permissions to delete the data for this secret.
|
|
||||||
</button>
|
|
||||||
{{/if}}
|
|
||||||
</li>
|
</li>
|
||||||
{{#if canDestroyVersion}}
|
|
||||||
<li class="action">
|
|
||||||
<ConfirmAction
|
|
||||||
@buttonClasses="link is-destroy"
|
|
||||||
@confirmTitle="Permanently delete?"
|
|
||||||
@confirmMessage="This version will no longer be able to be read, and cannot be undeleted."
|
|
||||||
@onConfirmAction={{action (queue (action D.actions.close) (action "deleteVersion" "destroy"))}}
|
|
||||||
data-test-secret-v2-destroy="true"
|
|
||||||
>
|
|
||||||
Permanently destroy version
|
|
||||||
</ConfirmAction>
|
|
||||||
</li>
|
|
||||||
{{else}}
|
|
||||||
<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>
|
||||||
|
|
|
@ -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,30 +22,50 @@
|
||||||
<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}}
|
||||||
<span class="has-text-success is-size-9">
|
|
||||||
<Icon @glyph="check-circle-outline" />Current
|
|
||||||
</span>
|
|
||||||
{{/if}}
|
|
||||||
</div>
|
</div>
|
||||||
<div class="column">
|
{{#if (eq list.item.version model.currentVersion)}}
|
||||||
{{#if list.item.deleted}}
|
<div class="column is-1">
|
||||||
<span class="has-text-grey is-size-8">
|
<span class="has-text-success is-size-9">
|
||||||
<Icon @glyph="cancel-square-outline" />Deleted
|
<Icon @glyph="check-circle-fill" />Current
|
||||||
</span>
|
</span>
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
|
{{#if list.item.deleted}}
|
||||||
|
<div class="column is-1">
|
||||||
|
<span class="has-text-grey is-size-8">
|
||||||
|
<Icon @glyph="cancel-square-fill" />Deleted
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
{{#if list.item.destroyed}}
|
{{#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
|
||||||
|
@class="popup-menu"
|
||||||
|
@horizontalPosition="auto-right"
|
||||||
|
@verticalPosition="below"
|
||||||
|
as |D|
|
||||||
|
>
|
||||||
|
<D.trigger
|
||||||
|
data-test-popup-menu-trigger="true"
|
||||||
|
@class={{concat "toolbar-link" (if D.isOpen " is-active")}}
|
||||||
|
@tagName="button"
|
||||||
|
>
|
||||||
|
<Icon aria-label="More options" @glyph="more-horizontal" class="has-text-black auto-width" />
|
||||||
|
</D.trigger>
|
||||||
|
<D.content @class="popup-menu-content ">
|
||||||
|
<nav class="box menu">
|
||||||
|
<ul class="menu-list">
|
||||||
<li class="action">
|
<li class="action">
|
||||||
<SecretLink
|
<SecretLink
|
||||||
@data-test-version
|
@data-test-version
|
||||||
|
@ -62,7 +87,10 @@
|
||||||
Create new version from {{list.item.version}}
|
Create new version from {{list.item.version}}
|
||||||
</SecretLink>
|
</SecretLink>
|
||||||
</li>
|
</li>
|
||||||
</SecretVersionMenu>
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</D.content>
|
||||||
|
</BasicDropdown>
|
||||||
</Item.menu>
|
</Item.menu>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
</ListView>
|
</ListView>
|
||||||
|
|
|
@ -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();
|
||||||
|
|
|
@ -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();
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
Loading…
Reference in a new issue