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 @@ + +
+ {{!-- Left side version --}} + + + Version {{or this.leftSideVersionSelected this.args.model.currentVersion}} + + + + + + + {{!-- Right side version --}} + + + Version {{or this.rightSideVersionSelected this.rightSideVersionInit}} + + + + + + + {{!-- Status --}} + {{#if this.statesMatch}} +
+ States match + +
+ {{/if}} +
+
+ +
+
{{{this.visualDiff}}}
+
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 }} -
-

{{ 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 }} +
+

{{ @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"