diff --git a/changelog/13000.txt b/changelog/13000.txt
new file mode 100644
index 000000000..daddd07c3
--- /dev/null
+++ b/changelog/13000.txt
@@ -0,0 +1,3 @@
+```release-note:improvement
+ui: Add version diff view for KV V2
+```
\ No newline at end of file
diff --git a/ui/app/adapters/secret-v2-version.js b/ui/app/adapters/secret-v2-version.js
index 0672971ca..a11ae1dc2 100644
--- a/ui/app/adapters/secret-v2-version.js
+++ b/ui/app/adapters/secret-v2-version.js
@@ -54,6 +54,16 @@ export default ApplicationAdapter.extend({
});
},
+ querySecretDataByVersion(id) {
+ return this.ajax(this.urlForQueryRecord(id), 'GET')
+ .then(resp => {
+ return resp.data;
+ })
+ .catch(error => {
+ return error.data;
+ });
+ },
+
urlForCreateRecord(modelName, snapshot) {
let backend = snapshot.belongsTo('secret').belongsTo('engine').id;
let path = snapshot.attr('path');
diff --git a/ui/app/components/diff-version-selector.js b/ui/app/components/diff-version-selector.js
new file mode 100644
index 000000000..30df62e45
--- /dev/null
+++ b/ui/app/components/diff-version-selector.js
@@ -0,0 +1,83 @@
+/* eslint-disable no-undef */
+import Component from '@glimmer/component';
+import { inject as service } from '@ember/service';
+import { action } from '@ember/object';
+import { tracked } from '@glimmer/tracking';
+
+/**
+ * @module DiffVersionSelector
+ * DiffVersionSelector component includes a toolbar and diff view between KV 2 versions. It uses the library jsondiffpatch.
+ *
+ * @example
+ * ```js
+ *
+ * ```
+ * @param {object} model - model that comes from secret-v2-version
+ */
+
+export default class DiffVersionSelector extends Component {
+ @tracked leftSideVersionDataSelected = null;
+ @tracked leftSideVersionSelected = null;
+ @tracked rightSideVersionDataSelected = null;
+ @tracked rightSideVersionSelected = null;
+ @tracked statesMatch = false;
+ @tracked visualDiff = null;
+ @service store;
+
+ adapter = this.store.adapterFor('secret-v2-version');
+
+ constructor() {
+ super(...arguments);
+ this.createVisualDiff();
+ }
+
+ get leftSideDataInit() {
+ let string = `["${this.args.model.engineId}", "${this.args.model.id}", "${this.args.model.currentVersion}"]`;
+ return this.adapter
+ .querySecretDataByVersion(string)
+ .then(response => response.data)
+ .catch(() => null);
+ }
+ get rightSideDataInit() {
+ let string = `["${this.args.model.engineId}", "${this.args.model.id}", "${this.rightSideVersionInit}"]`;
+ return this.adapter
+ .querySecretDataByVersion(string)
+ .then(response => response.data)
+ .catch(() => null);
+ }
+ get rightSideVersionInit() {
+ // initial value of right side version is one less than the current version
+ return this.args.model.currentVersion === 1 ? 0 : this.args.model.currentVersion - 1;
+ }
+
+ async createVisualDiff() {
+ let diffpatcher = jsondiffpatch.create({});
+ let leftSideVersionData = this.leftSideVersionDataSelected || (await this.leftSideDataInit);
+ let rightSideVersionData = this.rightSideVersionDataSelected || (await this.rightSideDataInit);
+ let delta = diffpatcher.diff(rightSideVersionData, leftSideVersionData);
+ if (delta === undefined) {
+ this.statesMatch = true;
+ this.visualDiff = JSON.stringify(leftSideVersionData, undefined, 2); // params: value, replacer (all properties included), space (white space and indentation, line break, etc.)
+ } else {
+ this.statesMatch = false;
+ this.visualDiff = jsondiffpatch.formatters.html.format(delta, rightSideVersionData);
+ }
+ }
+
+ @action
+ async selectVersion(selectedVersion, actions, side) {
+ let string = `["${this.args.model.engineId}", "${this.args.model.id}", "${selectedVersion}"]`;
+ let secretData = await this.adapter.querySecretDataByVersion(string);
+ if (side === 'left') {
+ this.leftSideVersionDataSelected = secretData.data;
+ this.leftSideVersionSelected = selectedVersion;
+ }
+ if (side === 'right') {
+ this.rightSideVersionDataSelected = secretData.data;
+ this.rightSideVersionSelected = selectedVersion;
+ }
+ await this.createVisualDiff();
+ // close dropdown menu.
+ actions.close();
+ }
+}
diff --git a/ui/app/components/json-editor.js b/ui/app/components/json-editor.js
index e77f41b73..1e57e84ec 100644
--- a/ui/app/components/json-editor.js
+++ b/ui/app/components/json-editor.js
@@ -1,4 +1,21 @@
-import Component from '@ember/component';
+import Component from '@glimmer/component';
+import { action } from '@ember/object';
+
+/**
+ * @module JsonEditor
+ *
+ * @example
+ * ```js
+ *
+ * ```
+ *
+ * @param {string} [title] - Name above codemirror view
+ * @param {string} value - a specific string the comes from codemirror. It's the value inside the codemirror display
+ * @param {Function} [valueUpdated] - action to preform when you edit the codemirror value.
+ * @param {Function} [onFocusOut] - action to preform when you focus out of codemirror.
+ * @param {string} [helpText] - helper text.
+ * @param {object} [options] - option object that overrides codemirror default options such as the styling.
+ */
const JSON_EDITOR_DEFAULTS = {
// IMPORTANT: `gutters` must come before `lint` since the presence of
@@ -13,20 +30,16 @@ const JSON_EDITOR_DEFAULTS = {
showCursorWhenSelecting: true,
};
-export default Component.extend({
- showToolbar: true,
- title: null,
- subTitle: null,
- helpText: null,
- value: null,
- options: null,
- valueUpdated: null,
- onFocusOut: null,
- readOnly: false,
+export default class JsonEditorComponent extends Component {
+ value = null;
+ valueUpdated = null;
+ onFocusOut = null;
+ readOnly = false;
+ options = null;
- init() {
- this._super(...arguments);
- this.options = { ...JSON_EDITOR_DEFAULTS, ...this.options };
+ constructor() {
+ super(...arguments);
+ this.options = { ...JSON_EDITOR_DEFAULTS, ...this.args.options };
if (this.options.autoHeight) {
this.options.viewportMargin = Infinity;
delete this.options.autoHeight;
@@ -36,18 +49,23 @@ export default Component.extend({
this.options.lineNumbers = false;
delete this.options.gutters;
}
- },
+ }
- actions: {
- updateValue(...args) {
- if (this.valueUpdated) {
- this.valueUpdated(...args);
- }
- },
- onFocus(...args) {
- if (this.onFocusOut) {
- this.onFocusOut(...args);
- }
- },
- },
-});
+ get getShowToolbar() {
+ return this.args.showToolbar === false ? false : true;
+ }
+
+ @action
+ updateValue(...args) {
+ if (this.args.valueUpdated) {
+ this.args.valueUpdated(...args);
+ }
+ }
+
+ @action
+ onFocus(...args) {
+ if (this.args.onFocusOut) {
+ this.args.onFocusOut(...args);
+ }
+ }
+}
diff --git a/ui/app/controllers/vault/cluster/secrets/backend/diff.js b/ui/app/controllers/vault/cluster/secrets/backend/diff.js
new file mode 100644
index 000000000..752bb56aa
--- /dev/null
+++ b/ui/app/controllers/vault/cluster/secrets/backend/diff.js
@@ -0,0 +1,4 @@
+import Controller from '@ember/controller';
+import BackendCrumbMixin from 'vault/mixins/backend-crumb';
+
+export default class DiffController extends Controller.extend(BackendCrumbMixin) {}
diff --git a/ui/app/router.js b/ui/app/router.js
index 900e3a1fb..c3f4c04c4 100644
--- a/ui/app/router.js
+++ b/ui/app/router.js
@@ -103,6 +103,7 @@ Router.map(function() {
this.route('list', { path: '/list/*secret' });
this.route('show', { path: '/show/*secret' });
+ this.route('diff', { path: '/diff/*id' });
this.route('metadata', { path: '/metadata/*secret' });
this.route('edit-metadata', { path: '/edit-metadata/*secret' });
this.route('create', { path: '/create/*secret' });
diff --git a/ui/app/routes/vault/cluster/secrets/backend/diff.js b/ui/app/routes/vault/cluster/secrets/backend/diff.js
new file mode 100644
index 000000000..6ffb2e95d
--- /dev/null
+++ b/ui/app/routes/vault/cluster/secrets/backend/diff.js
@@ -0,0 +1,25 @@
+import Route from '@ember/routing/route';
+import { inject as service } from '@ember/service';
+
+export default class diff extends Route {
+ @service store;
+
+ beforeModel() {
+ let { backend } = this.paramsFor('vault.cluster.secrets.backend');
+ this.backend = backend;
+ }
+
+ model(params) {
+ let { id } = params;
+ return this.store.queryRecord('secret-v2', {
+ backend: this.backend,
+ id,
+ });
+ }
+
+ setupController(controller, model) {
+ controller.set('backend', this.backend); // for backendCrumb
+ controller.set('id', model.id); // for navigation on tabs
+ controller.set('model', model);
+ }
+}
diff --git a/ui/app/styles/components/diff-version-selector.scss b/ui/app/styles/components/diff-version-selector.scss
new file mode 100644
index 000000000..13c1d2345
--- /dev/null
+++ b/ui/app/styles/components/diff-version-selector.scss
@@ -0,0 +1,24 @@
+.visual-diff {
+ background-color: black;
+
+ pre {
+ color: $ui-gray-010;
+ }
+}
+
+.jsondiffpatch-deleted .jsondiffpatch-property-name,
+.jsondiffpatch-deleted pre,
+.jsondiffpatch-modified .jsondiffpatch-left-value pre,
+.jsondiffpatch-textdiff-deleted {
+ background: $red-500;
+}
+.jsondiffpatch-added .jsondiffpatch-property-name,
+.jsondiffpatch-added .jsondiffpatch-value pre,
+.jsondiffpatch-modified .jsondiffpatch-right-value pre,
+.jsondiffpatch-textdiff-added {
+ background: $green-500;
+}
+
+.jsondiffpatch-property-name {
+ color: $ui-gray-300;
+}
diff --git a/ui/app/styles/components/toolbar.scss b/ui/app/styles/components/toolbar.scss
index ea7967ba1..549be7a15 100644
--- a/ui/app/styles/components/toolbar.scss
+++ b/ui/app/styles/components/toolbar.scss
@@ -118,3 +118,14 @@
margin: 0 $spacing-xs;
width: 0;
}
+
+.version-diff-toolbar {
+ display: flex;
+ align-items: baseline;
+ gap: $spacing-s;
+
+ .diff-status {
+ display: flex;
+ direction: rtl;
+ }
+}
diff --git a/ui/app/styles/core.scss b/ui/app/styles/core.scss
index 06b5f9a15..bce462cd3 100644
--- a/ui/app/styles/core.scss
+++ b/ui/app/styles/core.scss
@@ -52,6 +52,7 @@
@import './components/confirm';
@import './components/console-ui-panel';
@import './components/control-group';
+@import './components/diff-version-selector';
@import './components/doc-link';
@import './components/empty-state';
@import './components/env-banner';
diff --git a/ui/app/templates/components/diff-version-selector.hbs b/ui/app/templates/components/diff-version-selector.hbs
new file mode 100644
index 000000000..fd02a163f
--- /dev/null
+++ b/ui/app/templates/components/diff-version-selector.hbs
@@ -0,0 +1,94 @@
+
+
+
+
+
diff --git a/ui/app/templates/components/json-editor.hbs b/ui/app/templates/components/json-editor.hbs
index 5752bb56a..78e72c042 100644
--- a/ui/app/templates/components/json-editor.hbs
+++ b/ui/app/templates/components/json-editor.hbs
@@ -1,32 +1,36 @@
-{{#if showToolbar }}
-
-
-
-
- {{yield}}
-
-
-
-
-
-
-
-{{/if}}
-{{ivy-codemirror
- data-test-component="json-editor"
- value=value
- options=options
- valueUpdated=(action "updateValue")
- onFocusOut=(action "onFocus")
-}}
-{{#if helpText }}
-
-{{/if}}
\ No newline at end of file
+
+ {{#if this.getShowToolbar }}
+
+
+
+
+ {{yield}}
+
+
+
+
+
+
+
+ {{/if}}
+
+ {{ivy-codemirror
+ data-test-component="json-editor"
+ value=@value
+ options=this.options
+ valueUpdated=(action "updateValue")
+ onFocusOut=(action "onFocus")
+ }}
+
+ {{#if @helpText }}
+
+ {{/if}}
+
diff --git a/ui/app/templates/components/secret-version-menu.hbs b/ui/app/templates/components/secret-version-menu.hbs
index 672be6d5c..2867800e8 100644
--- a/ui/app/templates/components/secret-version-menu.hbs
+++ b/ui/app/templates/components/secret-version-menu.hbs
@@ -43,6 +43,20 @@
View version history
+ {{#if (gt @model.versions.length 1)}}
+
+
+
+ View diff
+
+
+
+ {{/if}}
diff --git a/ui/app/templates/vault/cluster/secrets/backend/diff.hbs b/ui/app/templates/vault/cluster/secrets/backend/diff.hbs
new file mode 100644
index 000000000..b2deee73a
--- /dev/null
+++ b/ui/app/templates/vault/cluster/secrets/backend/diff.hbs
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+ View diff
+
+
+
+
+
diff --git a/ui/ember-cli-build.js b/ui/ember-cli-build.js
index 2fb7353ac..a04d6256e 100644
--- a/ui/ember-cli-build.js
+++ b/ui/ember-cli-build.js
@@ -71,6 +71,8 @@ module.exports = function(defaults) {
app.import('node_modules/codemirror/addon/lint/lint.js');
app.import('node_modules/codemirror/addon/lint/json-lint.js');
app.import('node_modules/text-encoder-lite/text-encoder-lite.js');
+ app.import('node_modules/jsondiffpatch/dist/jsondiffpatch.umd.js');
+ app.import('node_modules/jsondiffpatch/dist/formatters-styles/html.css');
app.import('app/styles/bulma/bulma-radio-checkbox.css');
diff --git a/ui/package.json b/ui/package.json
index b824c39ed..768d300e9 100644
--- a/ui/package.json
+++ b/ui/package.json
@@ -133,6 +133,7 @@
"filesize": "^4.2.1",
"flat": "^4.1.0",
"ivy-codemirror": "IvyApp/ivy-codemirror#fb09333c5144da47e14a9e6260f80577d5408374",
+ "jsondiffpatch": "^0.4.1",
"jsonlint": "^1.6.3",
"loader.js": "^4.7.0",
"node-forge": "^0.10.0",
diff --git a/ui/tests/acceptance/secrets/backend/kv/diff-test.js b/ui/tests/acceptance/secrets/backend/kv/diff-test.js
new file mode 100644
index 000000000..97bb7663c
--- /dev/null
+++ b/ui/tests/acceptance/secrets/backend/kv/diff-test.js
@@ -0,0 +1,69 @@
+import { click, settled, fillIn } from '@ember/test-helpers';
+import { create } from 'ember-cli-page-object';
+import { module, test } from 'qunit';
+import { setupApplicationTest } from 'ember-qunit';
+import editPage from 'vault/tests/pages/secrets/backend/kv/edit-secret';
+import listPage from 'vault/tests/pages/secrets/backend/list';
+import apiStub from 'vault/tests/helpers/noop-all-api-requests';
+import authPage from 'vault/tests/pages/auth';
+import consoleClass from 'vault/tests/pages/components/console/ui-panel';
+
+const consoleComponent = create(consoleClass);
+
+module('Acceptance | kv2 diff view', function(hooks) {
+ setupApplicationTest(hooks);
+
+ hooks.beforeEach(async function() {
+ this.server = apiStub({ usePassthrough: true });
+ return authPage.login();
+ });
+
+ hooks.afterEach(function() {
+ this.server.shutdown();
+ });
+
+ test('it shows correct diff status based on versions', async function(assert) {
+ const secretPath = `my-secret`;
+
+ await consoleComponent.runCommands([
+ `write sys/mounts/secret type=kv options=version=2`,
+ // delete any kv previously written here so that tests can be re-run
+ `delete secret/metadata/${secretPath}`,
+ 'write -field=client_token auth/token/create policies=kv-v2-degrade',
+ ]);
+
+ await listPage.visitRoot({ backend: 'secret' });
+ await settled();
+ await listPage.create();
+ await settled();
+ await editPage.createSecret(secretPath, 'version1', 'hello');
+ await settled();
+ await click('[data-test-popup-menu-trigger="version"]');
+ await settled();
+ assert.dom('[data-test-view-diff]').doesNotExist('does not show diff view with only one version');
+ // add another version
+ await click('[data-test-secret-edit="true"]');
+ await settled();
+
+ let secondKey = document.querySelectorAll('[data-test-secret-key]')[1];
+ let secondValue = document.querySelectorAll('.masked-value')[1];
+ await fillIn(secondKey, 'version2');
+ await fillIn(secondValue, 'world!');
+ await click('[data-test-secret-save]');
+ await settled();
+ await click('[data-test-popup-menu-trigger="version"]');
+ await settled();
+ assert.dom('[data-test-view-diff]').exists('does show diff view with two versions');
+
+ await click('[data-test-view-diff]');
+ await settled();
+ let diffBetweenVersion2and1 = document.querySelector('.jsondiffpatch-added').innerText;
+ assert.equal(diffBetweenVersion2and1, 'version2"world!"', 'shows the correct added part');
+
+ await click('[data-test-popup-menu-trigger="right-version"]');
+ await settled();
+ await click('[data-test-rightSide-version="2"]');
+ await settled();
+ assert.dom('.diff-status').exists('shows States Match');
+ });
+});
diff --git a/ui/tests/integration/components/diff-version-selector-test.js b/ui/tests/integration/components/diff-version-selector-test.js
new file mode 100644
index 000000000..8e8a62984
--- /dev/null
+++ b/ui/tests/integration/components/diff-version-selector-test.js
@@ -0,0 +1,38 @@
+import EmberObject from '@ember/object';
+import { module, test } from 'qunit';
+import { setupRenderingTest } from 'ember-qunit';
+import { render, click, settled } from '@ember/test-helpers';
+import { hbs } from 'ember-cli-htmlbars';
+
+const VERSIONS = [
+ {
+ version: 2,
+ },
+ {
+ version: 1,
+ },
+];
+
+module('Integration | Component | diff-version-selector', function(hooks) {
+ setupRenderingTest(hooks);
+
+ test('it renders', async function(assert) {
+ this.set(
+ 'model',
+ EmberObject.create({
+ currentVersion: 2,
+ versions: VERSIONS,
+ })
+ );
+ await render(hbs``);
+ let leftSideVersion = document
+ .querySelector('[data-test-popup-menu-trigger="left-version"]')
+ .innerText.trim();
+ assert.equal(leftSideVersion, 'Version 2', 'left side toolbar defaults to currentVersion');
+
+ await click('[data-test-popup-menu-trigger="left-version"]');
+ await settled();
+ assert.dom('[data-test-leftSide-version="1"]').exists('leftside shows both versions');
+ assert.dom('[data-test-leftSide-version="2"]').exists('leftside shows both versions');
+ });
+});
diff --git a/ui/yarn.lock b/ui/yarn.lock
index d28e1eda7..4b537a4b9 100644
--- a/ui/yarn.lock
+++ b/ui/yarn.lock
@@ -8540,6 +8540,11 @@ detect-port@^1.3.0:
address "^1.0.1"
debug "^2.6.0"
+diff-match-patch@^1.0.0:
+ version "1.0.5"
+ resolved "https://registry.yarnpkg.com/diff-match-patch/-/diff-match-patch-1.0.5.tgz#abb584d5f10cd1196dfc55aa03701592ae3f7b37"
+ integrity sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw==
+
diff@^3.5.0:
version "3.5.0"
resolved "https://registry.yarnpkg.com/diff/-/diff-3.5.0.tgz#800c0dd1e0a8bfbc95835c202ad220fe317e5a12"
@@ -13642,6 +13647,14 @@ json5@^2.1.2, json5@^2.1.3:
dependencies:
minimist "^1.2.5"
+jsondiffpatch@^0.4.1:
+ version "0.4.1"
+ resolved "https://registry.yarnpkg.com/jsondiffpatch/-/jsondiffpatch-0.4.1.tgz#9fb085036767f03534ebd46dcd841df6070c5773"
+ integrity sha512-t0etAxTUk1w5MYdNOkZBZ8rvYYN5iL+2dHCCx/DpkFm/bW28M6y5nUS83D4XdZiHy35Fpaw6LBb+F88fHZnVCw==
+ dependencies:
+ chalk "^2.3.0"
+ diff-match-patch "^1.0.0"
+
jsonfile@^2.1.0:
version "2.4.0"
resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-2.4.0.tgz#3736a2b428b87bbda0cc83b53fa3d633a35c2ae8"