diff --git a/ui/app/app.js b/ui/app/app.js index b8675a7383..067a6f8c87 100644 --- a/ui/app/app.js +++ b/ui/app/app.js @@ -14,6 +14,11 @@ App = Application.extend({ podModulePrefix: config.podModulePrefix, Resolver, engines: { + openApiExplorer: { + dependencies: { + services: ['auth', 'flash-messages', 'namespace', 'router', 'version'], + }, + }, replication: { dependencies: { services: [ diff --git a/ui/app/components/console/ui-panel.js b/ui/app/components/console/ui-panel.js index 9d68704bfd..788db5ed1b 100644 --- a/ui/app/components/console/ui-panel.js +++ b/ui/app/components/console/ui-panel.js @@ -45,13 +45,13 @@ export default Component.extend({ let serviceArgs; if ( - executeUICommand( - command, - args => this.logAndOutput(args), - args => service.clearLog(args), - () => this.toggleProperty('isFullscreen'), - () => this.get('refreshRoute').perform() - ) + executeUICommand(command, args => this.logAndOutput(args), { + api: () => this.routeToExplore.perform(command), + clearall: () => service.clearLog(true), + clear: () => service.clearLog(), + fullscreen: () => this.toggleProperty('isFullscreen'), + refresh: () => this.refreshRoute.perform(), + }) ) { return; } @@ -104,6 +104,29 @@ export default Component.extend({ } }), + routeToExplore: task(function*(command) { + let filter = command.replace('api', '').trim(); + try { + yield this.router.transitionTo('vault.cluster.open-api-explorer.index', { + queryParams: { filter }, + }); + let content = + 'Welcome to the Vault API explorer! \nYou can search for endpoints, see what parameters they accept, and even execute requests with your current token.'; + if (filter) { + content = `Welcome to the Vault API explorer! \nWe've filtered the list of endpoints for '${filter}'.`; + } + this.logAndOutput(null, { + type: 'success', + content, + }); + } catch (error) { + this.logAndOutput(null, { + type: 'error', + content: 'There was a problem navigating to the api explorer.', + }); + } + }), + shiftCommandIndex(keyCode) { this.get('console').shiftCommandIndex(keyCode, val => { this.set('inputValue', val); diff --git a/ui/app/lib/console-helpers.js b/ui/app/lib/console-helpers.js index f68e494627..65ea1b3c56 100644 --- a/ui/app/lib/console-helpers.js +++ b/ui/app/lib/console-helpers.js @@ -2,7 +2,7 @@ import keys from 'vault/lib/keycodes'; import argTokenizer from 'yargs-parser/lib/tokenize-arg-string.js'; const supportedCommands = ['read', 'write', 'list', 'delete']; -const uiCommands = ['clearall', 'clear', 'fullscreen', 'refresh']; +const uiCommands = ['api', 'clearall', 'clear', 'fullscreen', 'refresh']; export function extractDataAndFlags(data, flags) { return data.concat(flags).reduce( @@ -32,26 +32,15 @@ export function extractDataAndFlags(data, flags) { ); } -export function executeUICommand(command, logAndOutput, clearLog, toggleFullscreen, refreshFn) { - const isUICommand = uiCommands.includes(command); +export function executeUICommand(command, logAndOutput, commandFns) { + let cmd = command.startsWith('api') ? 'api' : command; + let isUICommand = uiCommands.includes(cmd); if (isUICommand) { logAndOutput(command); } - switch (command) { - case 'clearall': - clearLog(true); - break; - case 'clear': - clearLog(); - break; - case 'fullscreen': - toggleFullscreen(); - break; - case 'refresh': - refreshFn(); - break; + if (typeof commandFns[cmd] === 'function') { + commandFns[cmd](); } - return isUICommand; } diff --git a/ui/app/models/role-jwt.js b/ui/app/models/role-jwt.js index 0083b6e4d9..fda75df709 100644 --- a/ui/app/models/role-jwt.js +++ b/ui/app/models/role-jwt.js @@ -1,6 +1,6 @@ import DS from 'ember-data'; import { computed } from '@ember/object'; -import parseURL from 'vault/utils/parse-url'; +import parseURL from 'core/utils/parse-url'; const { attr } = DS; const DOMAIN_STRINGS = { diff --git a/ui/app/router.js b/ui/app/router.js index 1845a619f1..10796047ba 100644 --- a/ui/app/router.js +++ b/ui/app/router.js @@ -13,6 +13,7 @@ Router.map(function() { this.route('auth'); this.route('init'); this.route('logout'); + this.mount('open-api-explorer', { path: '/api-explorer' }); this.route('license'); this.route('requests', { path: '/metrics/requests' }); this.route('settings', function() { diff --git a/ui/app/styles/core/message.scss b/ui/app/styles/core/message.scss index e3dc315d80..f1a60248ea 100644 --- a/ui/app/styles/core/message.scss +++ b/ui/app/styles/core/message.scss @@ -35,6 +35,9 @@ border: 0; margin-top: $spacing-xxs; } + .message-body.pre { + white-space: pre-wrap; + } p { font-size: $size-8; diff --git a/ui/app/templates/components/alert-popup.hbs b/ui/app/templates/components/alert-popup.hbs index 44a44d4e5d..c6d73c1fa9 100644 --- a/ui/app/templates/components/alert-popup.hbs +++ b/ui/app/templates/components/alert-popup.hbs @@ -18,9 +18,7 @@ {{type.text}} {{#if message}} -
+ {{/if}} diff --git a/ui/app/templates/components/console/log-help.hbs b/ui/app/templates/components/console/log-help.hbs index e882420336..6d9aa8419f 100644 --- a/ui/app/templates/components/console/log-help.hbs +++ b/ui/app/templates/components/console/log-help.hbs @@ -9,9 +9,10 @@ Commands: list List data or secrets Web CLI Commands: - fullscreen Toggle fullscreen display + api Navigate to the Vault API explorer. Use 'api [filter]' to prefilter the list. clear Clear output from the log clearall Clear output and command history + fullscreen Toggle fullscreen display refresh Refresh the data on the current screen under the CLI window diff --git a/ui/app/templates/vault/cluster.hbs b/ui/app/templates/vault/cluster.hbs index 7e5d3bb888..397527efd3 100644 --- a/ui/app/templates/vault/cluster.hbs +++ b/ui/app/templates/vault/cluster.hbs @@ -95,7 +95,7 @@ {{#if flash.componentName}} {{component flash.componentName content=flash.content}} {{else}} -- This {{noun}} will be {{modeVerb}} in the {{namespace.path}}/namespace. -
+ {{#if (has-block)}} ++ {{yield (hash namespace=namespace)}} +
+ {{else}} ++ This {{noun}} will be {{modeVerb}} in the {{namespace.path}}/namespace. +
+ {{/if}} {{/if}} diff --git a/ui/app/utils/parse-url.js b/ui/lib/core/addon/utils/parse-url.js similarity index 100% rename from ui/app/utils/parse-url.js rename to ui/lib/core/addon/utils/parse-url.js diff --git a/ui/lib/open-api-explorer/addon/components/swagger-ui.js b/ui/lib/open-api-explorer/addon/components/swagger-ui.js new file mode 100644 index 0000000000..e5ab142488 --- /dev/null +++ b/ui/lib/open-api-explorer/addon/components/swagger-ui.js @@ -0,0 +1,105 @@ +import Component from '@ember/component'; +import { inject as service } from '@ember/service'; +import parseURL from 'core/utils/parse-url'; +import config from 'open-api-explorer/config/environment'; +import Swag from 'swagger-ui-dist'; + +const { SwaggerUIBundle } = Swag; +const { APP } = config; + +const SearchFilterPlugin = () => { + return { + fn: { + opsFilter: (taggedOps, phrase) => { + // map over the options and filter out operations where the path doesn't match what's typed + return ( + taggedOps + .map(tagObj => { + let operations = tagObj.get('operations').filter(operationObj => { + return operationObj.get('path').includes(phrase); + }); + return tagObj.set('operations', operations); + }) + // then traverse again and remove the top level item if there are no operations left after filtering + .filter(tagObj => !!tagObj.get('operations').size) + ); + }, + }, + }; +}; + +const CONFIG = (componentInstance, initialFilter) => { + return { + dom_id: `#${componentInstance.elementId}-swagger`, + url: '/v1/sys/internal/specs/openapi', + deepLinking: false, + presets: [SwaggerUIBundle.presets.apis], + plugins: [SwaggerUIBundle.plugins.DownloadUrl, SearchFilterPlugin], + // 'list' expands tags, but not operations + docExpansion: 'list', + operationsSorter: 'alpha', + filter: initialFilter || true, + // this makes sure we show the x-vault- options + showExtensions: true, + // we don't have any models defined currently + defaultModelsExpandDepth: -1, + defaultModelExpandDepth: 1, + requestInterceptor: req => { + // we need to add vault authorization header + // and namepace headers for things to work properly + req.headers['X-Vault-Token'] = componentInstance.auth.currentToken; + + let namespace = componentInstance.namespaceService.path; + if (namespace && !APP.NAMESPACE_ROOT_URLS.some(str => req.url.includes(str))) { + req.headers['X-Vault-Namespace'] = namespace; + } + // we want to link to the right JSON in swagger UI so + // it's already been pre-pended + if (!req.loadSpec) { + let { protocol, host, pathname } = parseURL(req.url); + //paths in the spec don't have /v1 in them, so we need to add that here + // http(s): vlt.io:4200 /sys/mounts + req.url = `${protocol}//${host}/v1${pathname}`; + } + return req; + }, + onComplete: () => { + componentInstance.set('swaggerLoading', false); + }, + }; +}; + +export default Component.extend({ + auth: service(), + namespaceService: service('namespace'), + initialFilter: null, + onFilterChange() {}, + swaggerLoading: true, + + didInsertElement() { + this._super(...arguments); + // trim any initial slashes + let initialFilter = this.initialFilter.replace(/^(\/)+/, ''); + SwaggerUIBundle(CONFIG(this, initialFilter)); + }, + + actions: { + // sets the filter so the query param is updated so we get sharable URLs + updateFilter(e) { + this.onFilterChange(e.target.value || ''); + }, + proxyEvent(e) { + let swaggerInput = this.element.querySelector('.operation-filter-input'); + // if this breaks because of a react upgrade, + // change this to + //let originalSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set; + //originalSetter.call(swaggerInput, e.target.value); + // see post on triggering react events externally for an explanation of + // why this works: https://stackoverflow.com/a/46012210 + let evt = new Event('input', { bubbles: true }); + evt.simulated = true; + swaggerInput.value = e.target.value.replace(/^(\/)+/, ''); + swaggerInput.dispatchEvent(evt); + }, + }, +}); diff --git a/ui/lib/open-api-explorer/addon/controllers/index.js b/ui/lib/open-api-explorer/addon/controllers/index.js new file mode 100644 index 0000000000..704157fa82 --- /dev/null +++ b/ui/lib/open-api-explorer/addon/controllers/index.js @@ -0,0 +1,6 @@ +import Controller from '@ember/controller'; + +export default Controller.extend({ + queryParams: ['filter'], + filter: '', +}); diff --git a/ui/lib/open-api-explorer/addon/engine.js b/ui/lib/open-api-explorer/addon/engine.js new file mode 100644 index 0000000000..9555408ea4 --- /dev/null +++ b/ui/lib/open-api-explorer/addon/engine.js @@ -0,0 +1,18 @@ +import Engine from 'ember-engines/engine'; +import loadInitializers from 'ember-load-initializers'; +import Resolver from './resolver'; +import config from './config/environment'; + +const { modulePrefix } = config; +/* eslint-disable ember/avoid-leaking-state-in-ember-objects */ +const Eng = Engine.extend({ + modulePrefix, + Resolver, + dependencies: { + services: ['auth', 'flash-messages', 'namespace', 'router', 'version'], + }, +}); + +loadInitializers(Eng, modulePrefix); + +export default Eng; diff --git a/ui/lib/open-api-explorer/addon/resolver.js b/ui/lib/open-api-explorer/addon/resolver.js new file mode 100644 index 0000000000..2fb563d6c0 --- /dev/null +++ b/ui/lib/open-api-explorer/addon/resolver.js @@ -0,0 +1,3 @@ +import Resolver from 'ember-resolver'; + +export default Resolver; diff --git a/ui/lib/open-api-explorer/addon/routes.js b/ui/lib/open-api-explorer/addon/routes.js new file mode 100644 index 0000000000..8d86db5431 --- /dev/null +++ b/ui/lib/open-api-explorer/addon/routes.js @@ -0,0 +1,5 @@ +import buildRoutes from 'ember-engines/routes'; + +export default buildRoutes(function() { + // Define your engine's route map here +}); diff --git a/ui/lib/open-api-explorer/addon/routes/index.js b/ui/lib/open-api-explorer/addon/routes/index.js new file mode 100644 index 0000000000..fef65b0eae --- /dev/null +++ b/ui/lib/open-api-explorer/addon/routes/index.js @@ -0,0 +1,20 @@ +import Route from '@ember/routing/route'; +import { inject as service } from '@ember/service'; + +export default Route.extend({ + flashMessages: service(), + // without an empty model hook here, ember likes to use the parent model, and then things get weird with + // query params, so here we're no-op'ing the model hook + model() {}, + afterModel() { + let warning = `The "Try it out" functionality in this API explorer will make requests to this Vault server on your behalf. + +IF YOUR TOKEN HAS THE PROPER CAPABILITIES, THIS WILL CREATE AND DELETE ITEMS ON THE VAULT SERVER. + +Your token will also be shown on the screen in the example curl command output.`; + this.flashMessages.warning(warning, { + sticky: true, + preformatted: true, + }); + }, +}); diff --git a/ui/lib/open-api-explorer/addon/styles/addon.css b/ui/lib/open-api-explorer/addon/styles/addon.css new file mode 100644 index 0000000000..cebb78cbbc --- /dev/null +++ b/ui/lib/open-api-explorer/addon/styles/addon.css @@ -0,0 +1,159 @@ +/*THIS FILE LOADS AFTER THE SWAGGER-UI CSS, SO WE'LL USE IT TO OVERRIDE STYLES */ + + + +.swagger-ui .wrapper { + padding: 0; +} + +.swagger-ui .info { + margin: 25px 0; +} + +/*hide the swagger-ui headers*/ +.swagger-ui .filter-container, +.swagger-ui .information-container.wrapper { + display: none; +} + +/*some general de-rounding and removing backgrounds and drop shadows*/ +.swagger-ui .btn { + border-width: 1px; + box-shadow: none; + border-radius: 0px; +} + +.swagger-ui .opblock { + background: none; + border-width: 1px; + border-radius: 2px; + box-shadow: none; +} + + +/*START: customize method, path, description so that it's formatted like this:*/ +/* {method} {path/to/api} */ +/* {A lengthy description goes here} */ +.swagger-ui .opblock .opblock-summary, +.swagger-ui .opblock .opblock-summary-description { + display: block; + margin: 0; + padding: 0; +} + +.swagger-ui .opblock .opblock-summary { + padding: 1rem; +} + +.swagger-ui .opblock .opblock-summary-description { + font-size: 14px; +} + +.swagger-ui .opblock .opblock-summary-method, +.swagger-ui .opblock .opblock-summary-path{ + display: inline-block; + margin: 0; + padding: 0; +} + +.swagger-ui .opblock .opblock-summary-method { + border-radius: 1px; + min-width: auto; + text-align: left; + font-size: 10px; + box-shadow: 0 0 0 1px currentColor; + position: relative; + top: -2px; + padding: 0 2px; + margin-right: 8px; +} +/*END: customize method, path, description*/ + +/*START: make tags look like list items */ +.swagger-ui .opblock-tag{ + font-size: 16px; +} + +.swagger-ui .opblock-tag-section .opblock-tag { + color: #0a0a0a; + font-weight: 600 !important; + font-size: 1rem !important; + transition: box-shadow 150ms, margin 150ms, padding 150ms; + will-change: box-shadow, margin, padding; + background-color: white; + border-radius: 0; + padding: 1.25rem; + margin: 0; +} + +.swagger-ui .opblock-tag:hover, +.swagger-ui .opblock-tag:focus, +.swagger-ui .opblock-tag:active { + margin-left: -0.75rem !important; + margin-right: -0.75rem !important; + padding-left: 0.75rem; + padding-right: 0.75rem; + position: relative; + box-shadow: 0 2px 0 -1px #BAC1CC, 0 -2px 0 -1px #BAC1CC, 0 0 0 1px #BAC1CC, 0 8px 4px -4px rgba(10, 10, 10, 0.1), 0 6px 8px -2px rgba(10, 10, 10, 0.05); +} + +/*shrink the size of the arrows*/ +.swagger-ui .expand-methods svg, .swagger-ui .expand-operation svg { + height: 12px; + width: 12px; +} +/*END: make tags look like list items */ + + +/*operation box - GET (blue) */ +.swagger-ui .opblock.opblock-get { + background: #f5f8ff; + border: 1px solid #bfd4ff; +} + +/*operation label*/ +.swagger-ui .opblock.opblock-get .opblock-summary-method { + color: #1563ff; + background: none; +} + /*and expanded tab highlight */ +.swagger-ui .opblock.opblock-get .tab-header .tab-item.active h4 span::after { + background: #1563ff; +} + + +/*operation box - POST (green) */ +.swagger-ui .opblock.opblock-post { + background: #fafdfa; + border: 1px solid #c6e9c9; +} +.swagger-ui .opblock.opblock-post .opblock-summary-method { + color: #2eb039; + background: none; +} +.swagger-ui .opblock.opblock-post .tab-header .tab-item.active h4 span::after { + background: #2eb039; +} + +/*operation box - POST (red) */ +.swagger-ui .opblock.opblock-delete { + background: #fdfafb; + border: 1px solid #f9ecee; +} +.swagger-ui .opblock.opblock-delete .opblock-summary-method { + color: #c73445; + background: none; +} +.swagger-ui .opblock.opblock-delete .tab-header .tab-item.active h4 span::after { + background: #c73445; +} + +/*remove "LOADING" from initial loading spinner*/ +.swagger-ui .loading-container .loading::after { + content: ""; +} + +/*add text about requests to a live vault server*/ +.swagger-ui .btn.execute::after { + content: " - send a request with your token to Vault." +} diff --git a/ui/lib/open-api-explorer/addon/templates/application.hbs b/ui/lib/open-api-explorer/addon/templates/application.hbs new file mode 100644 index 0000000000..c24cd68950 --- /dev/null +++ b/ui/lib/open-api-explorer/addon/templates/application.hbs @@ -0,0 +1 @@ +{{outlet}} diff --git a/ui/lib/open-api-explorer/addon/templates/components/swagger-ui.hbs b/ui/lib/open-api-explorer/addon/templates/components/swagger-ui.hbs new file mode 100644 index 0000000000..8cf1bee558 --- /dev/null +++ b/ui/lib/open-api-explorer/addon/templates/components/swagger-ui.hbs @@ -0,0 +1,44 @@ +X-Vault-Namespace: {{R.namespace.path}}. You can also use {{R.namespace.path}} as an API prefix. See