diff --git a/ui/.bowerrc b/ui/.bowerrc new file mode 100644 index 000000000..959e1696e --- /dev/null +++ b/ui/.bowerrc @@ -0,0 +1,4 @@ +{ + "directory": "bower_components", + "analytics": false +} diff --git a/ui/.editorconfig b/ui/.editorconfig new file mode 100644 index 000000000..219985c22 --- /dev/null +++ b/ui/.editorconfig @@ -0,0 +1,20 @@ +# EditorConfig helps developers define and maintain consistent +# coding styles between different editors and IDEs +# editorconfig.org + +root = true + + +[*] +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true +indent_style = space +indent_size = 2 + +[*.hbs] +insert_final_newline = false + +[*.{diff,md}] +trim_trailing_whitespace = false diff --git a/ui/.ember-cli b/ui/.ember-cli new file mode 100644 index 000000000..322549174 --- /dev/null +++ b/ui/.ember-cli @@ -0,0 +1,10 @@ +{ + /** + Ember CLI sends analytics information by default. The data is completely + anonymous, but there are times when you might want to disable this behavior. + + Setting `disableAnalytics` to true will prevent any data from being sent. + */ + "disableAnalytics": true, + "output-path": "../pkg/web_ui" +} diff --git a/ui/.eslintrc.js b/ui/.eslintrc.js new file mode 100644 index 000000000..7784839c1 --- /dev/null +++ b/ui/.eslintrc.js @@ -0,0 +1,24 @@ +module.exports = { + root: true, + parserOptions: { + ecmaVersion: 2017, + sourceType: 'module', + "ecmaFeatures": { + "experimentalObjectRestSpread": true + } + }, + extends: 'eslint:recommended', + env: { + browser: true, + es6: true, + }, + rules: { + "no-unused-vars": ["error", { "ignoreRestSiblings": true }] + }, + globals: { + base64js: true, + TextEncoderLite: true, + TextDecoderLite: true, + Duration: true + } +}; diff --git a/ui/.gitignore b/ui/.gitignore new file mode 100644 index 000000000..8fa39a63c --- /dev/null +++ b/ui/.gitignore @@ -0,0 +1,23 @@ +# See https://help.github.com/ignore-files/ for more about ignoring files. + +# compiled output +/dist +/tmp + +# dependencies +/node_modules +/bower_components + +# misc +/.sass-cache +/connect.lock +/coverage/* +/libpeerconnection.log +npm-debug.log* +yarn-error.log +testem.log + +# ember-try +.node_modules.ember-try/ +bower.json.ember-try +package.json.ember-try diff --git a/ui/.travis.yml b/ui/.travis.yml new file mode 100644 index 000000000..d98801699 --- /dev/null +++ b/ui/.travis.yml @@ -0,0 +1,25 @@ +--- +language: node_js +node_js: + - "4" + +sudo: false + +cache: + directories: + - $HOME/.npm + - $HOME/.cache # includes bowers cache + +before_install: + - npm config set spin false + - npm install -g bower + - bower --version + - npm install phantomjs-prebuilt + - node_modules/phantomjs-prebuilt/bin/phantomjs --version + +install: + - npm install + - bower install + +script: + - npm test diff --git a/ui/.watchmanconfig b/ui/.watchmanconfig new file mode 100644 index 000000000..e7834e3e4 --- /dev/null +++ b/ui/.watchmanconfig @@ -0,0 +1,3 @@ +{ + "ignore_dirs": ["tmp", "dist"] +} diff --git a/ui/README.md b/ui/README.md new file mode 100644 index 000000000..ae4964a23 --- /dev/null +++ b/ui/README.md @@ -0,0 +1,45 @@ +# vault + +This README outlines the details of collaborating on this Ember application. +A short introduction of this app could easily go here. + +## Prerequisites + +You will need the following things properly installed on your computer. + +* [Git](https://git-scm.com/) +* [Node.js](https://nodejs.org/) (with NPM) +* [Bower](https://bower.io/) +* [Ember CLI](https://ember-cli.com/) + + +## Running / Development + +* `ember serve` +* Visit your app at [http://localhost:4200](http://localhost:4200). + +### Code Generators + +Make use of the many generators for code, try `ember help generate` for more details + +### Running Tests + +* `ember test` +* `ember test --server` + +### Building + +* `ember build` (development) +* `ember build --environment production` (production) + +### Deploying + +Specify what it takes to deploy your app. + +## Further Reading / Useful Links + +* [ember.js](http://emberjs.com/) +* [ember-cli](https://ember-cli.com/) +* Development Browser Extensions + * [ember inspector for chrome](https://chrome.google.com/webstore/detail/ember-inspector/bmdblncegkenkacieihfhpjfppoconhi) + * [ember inspector for firefox](https://addons.mozilla.org/en-US/firefox/addon/ember-inspector/) diff --git a/ui/app/adapters/application.js b/ui/app/adapters/application.js new file mode 100644 index 000000000..df076ae8f --- /dev/null +++ b/ui/app/adapters/application.js @@ -0,0 +1,86 @@ +import Ember from 'ember'; +import DS from 'ember-data'; +import fetch from 'fetch'; + +const POLLING_URL_PATTERNS = ['sys/seal-status', 'sys/health', 'sys/replication/status']; + +export default DS.RESTAdapter.extend({ + auth: Ember.inject.service(), + + flashMessages: Ember.inject.service(), + + namespace: 'v1/sys', + + shouldReloadAll() { + return true; + }, + + shouldReloadRecord() { + return true; + }, + + shouldBackgroundReloadRecord() { + return false; + }, + + _preRequest(url, options) { + const token = this.get('auth.currentToken'); + if (token && !options.unauthenticated) { + options.headers = Ember.assign(options.headers || {}, { + 'X-Vault-Token': token, + }); + if (options.wrapTTL) { + Ember.assign(options.headers, { 'X-Vault-Wrap-TTL': options.wrapTTL }); + } + } + const isPolling = POLLING_URL_PATTERNS.some(str => url.includes(str)); + if (!isPolling) { + this.get('auth').setLastFetch(Date.now()); + } + if (this.get('auth.shouldRenew')) { + this.get('auth').renew(); + } + options.timeout = 60000; + return options; + }, + + ajax(url, type, options = {}) { + let opts = this._preRequest(url, options); + + return this._super(url, type, opts).then((...args) => { + const [resp] = args; + if (resp && resp.warnings) { + const flash = this.get('flashMessages'); + resp.warnings.forEach(message => { + flash.info(message); + }); + } + return Ember.RSVP.resolve(...args); + }); + }, + + // for use on endpoints that don't return JSON responses + rawRequest(url, type, options = {}) { + let opts = this._preRequest(url, options); + return fetch(url, { + method: type | 'GET', + headers: opts.headers | {}, + }).then(response => { + if (response.status >= 200 && response.status < 300) { + return Ember.RSVP.resolve(response); + } else { + return Ember.RSVP.reject(); + } + }); + }, + + handleResponse(status, headers, payload, requestData) { + const returnVal = this._super(...arguments); + // ember data errors don't have the status code, so we add it here + if (returnVal instanceof DS.AdapterError) { + Ember.set(returnVal, 'httpStatus', status); + Ember.set(returnVal, 'path', requestData.url); + } + return returnVal; + }, +}); diff --git a/ui/app/adapters/auth-config/_base.js b/ui/app/adapters/auth-config/_base.js new file mode 100644 index 000000000..f2cd6780e --- /dev/null +++ b/ui/app/adapters/auth-config/_base.js @@ -0,0 +1,38 @@ +import ApplicationAdapter from '../application'; + +export default ApplicationAdapter.extend({ + namespace: '/v1/auth', + + pathForType(modelType) { + // we want the last part of the path + const type = modelType.split('/').pop(); + if (type === 'identity-whitelist' || type === 'roletag-blacklist') { + return `tidy/${type}`; + } + return type; + }, + + buildURL(modelName, id, snapshot) { + const backendId = id ? id : snapshot.belongsTo('backend').id; + let url = `${this.get('namespace')}/${backendId}/config`; + // aws has a lot more config endpoints + if (modelName.includes('aws')) { + url = `${url}/${this.pathForType(modelName)}`; + } + return url; + }, + + createRecord(store, type, snapshot) { + const id = snapshot.belongsTo('backend').id; + return this._super(...arguments).then(() => { + return { id }; + }); + }, + + updateRecord(store, type, snapshot) { + const id = snapshot.belongsTo('backend').id; + return this._super(...arguments).then(() => { + return { id }; + }); + }, +}); diff --git a/ui/app/adapters/auth-config/aws/client.js b/ui/app/adapters/auth-config/aws/client.js new file mode 100644 index 000000000..6d61a851d --- /dev/null +++ b/ui/app/adapters/auth-config/aws/client.js @@ -0,0 +1,2 @@ +import AuthConfig from '../_base'; +export default AuthConfig.extend(); diff --git a/ui/app/adapters/auth-config/aws/identity-whitelist.js b/ui/app/adapters/auth-config/aws/identity-whitelist.js new file mode 100644 index 000000000..6d61a851d --- /dev/null +++ b/ui/app/adapters/auth-config/aws/identity-whitelist.js @@ -0,0 +1,2 @@ +import AuthConfig from '../_base'; +export default AuthConfig.extend(); diff --git a/ui/app/adapters/auth-config/aws/roletag-blacklist.js b/ui/app/adapters/auth-config/aws/roletag-blacklist.js new file mode 100644 index 000000000..6d61a851d --- /dev/null +++ b/ui/app/adapters/auth-config/aws/roletag-blacklist.js @@ -0,0 +1,2 @@ +import AuthConfig from '../_base'; +export default AuthConfig.extend(); diff --git a/ui/app/adapters/auth-config/gcp.js b/ui/app/adapters/auth-config/gcp.js new file mode 100644 index 000000000..21f5624ac --- /dev/null +++ b/ui/app/adapters/auth-config/gcp.js @@ -0,0 +1,2 @@ +import AuthConfig from './_base'; +export default AuthConfig.extend(); diff --git a/ui/app/adapters/auth-config/github.js b/ui/app/adapters/auth-config/github.js new file mode 100644 index 000000000..21f5624ac --- /dev/null +++ b/ui/app/adapters/auth-config/github.js @@ -0,0 +1,2 @@ +import AuthConfig from './_base'; +export default AuthConfig.extend(); diff --git a/ui/app/adapters/auth-config/kubernetes.js b/ui/app/adapters/auth-config/kubernetes.js new file mode 100644 index 000000000..21f5624ac --- /dev/null +++ b/ui/app/adapters/auth-config/kubernetes.js @@ -0,0 +1,2 @@ +import AuthConfig from './_base'; +export default AuthConfig.extend(); diff --git a/ui/app/adapters/auth-config/ldap.js b/ui/app/adapters/auth-config/ldap.js new file mode 100644 index 000000000..21f5624ac --- /dev/null +++ b/ui/app/adapters/auth-config/ldap.js @@ -0,0 +1,2 @@ +import AuthConfig from './_base'; +export default AuthConfig.extend(); diff --git a/ui/app/adapters/auth-config/okta.js b/ui/app/adapters/auth-config/okta.js new file mode 100644 index 000000000..21f5624ac --- /dev/null +++ b/ui/app/adapters/auth-config/okta.js @@ -0,0 +1,2 @@ +import AuthConfig from './_base'; +export default AuthConfig.extend(); diff --git a/ui/app/adapters/auth-config/radius.js b/ui/app/adapters/auth-config/radius.js new file mode 100644 index 000000000..21f5624ac --- /dev/null +++ b/ui/app/adapters/auth-config/radius.js @@ -0,0 +1,2 @@ +import AuthConfig from './_base'; +export default AuthConfig.extend(); diff --git a/ui/app/adapters/auth-method.js b/ui/app/adapters/auth-method.js new file mode 100644 index 000000000..03d564290 --- /dev/null +++ b/ui/app/adapters/auth-method.js @@ -0,0 +1,41 @@ +import Ember from 'ember'; +import ApplicationAdapter from './application'; +import DS from 'ember-data'; + +export default ApplicationAdapter.extend({ + url(path) { + const url = `${this.buildURL()}/auth`; + return path ? url + '/' + path : url; + }, + + // used in updateRecord on the model#tune action + pathForType() { + return 'mounts/auth'; + }, + + findAll() { + return this.ajax(this.url(), 'GET').catch(e => { + if (e instanceof DS.AdapterError) { + Ember.set(e, 'policyPath', 'sys/auth'); + } + throw e; + }); + }, + + createRecord(store, type, snapshot) { + const serializer = store.serializerFor(type.modelName); + const data = serializer.serialize(snapshot); + const path = snapshot.attr('path'); + + return this.ajax(this.url(path), 'POST', { data }).then(() => { + // ember data doesn't like 204s if it's not a DELETE + return { + data: Ember.assign({}, data, { path: path + '/', id: path }), + }; + }); + }, + + urlForDeleteRecord(id, modelName, snapshot) { + return this.url(snapshot.id); + }, +}); diff --git a/ui/app/adapters/capabilities.js b/ui/app/adapters/capabilities.js new file mode 100644 index 000000000..5c110f2ba --- /dev/null +++ b/ui/app/adapters/capabilities.js @@ -0,0 +1,26 @@ +import ApplicationAdapter from './application'; +import DS from 'ember-data'; +import Ember from 'ember'; + +export default ApplicationAdapter.extend({ + pathForType() { + return 'capabilities-self'; + }, + + findRecord(store, type, id) { + return this.ajax(this.buildURL(type), 'POST', { data: { path: id } }).catch(e => { + if (e instanceof DS.AdapterError) { + Ember.set(e, 'policyPath', 'sys/capabilities-self'); + } + throw e; + }); + }, + + queryRecord(store, type, query) { + const { id } = query; + return this.findRecord(store, type, id).then(resp => { + resp.path = id; + return resp; + }); + }, +}); diff --git a/ui/app/adapters/cluster.js b/ui/app/adapters/cluster.js new file mode 100644 index 000000000..b8bc36eaa --- /dev/null +++ b/ui/app/adapters/cluster.js @@ -0,0 +1,187 @@ +import Ember from 'ember'; +import ApplicationAdapter from './application'; +import DS from 'ember-data'; + +const { AdapterError } = DS; +const { assert, inject } = Ember; + +const ENDPOINTS = ['health', 'seal-status', 'tokens', 'token', 'seal', 'unseal', 'init', 'capabilities-self']; + +const REPLICATION_ENDPOINTS = { + reindex: 'reindex', + recover: 'recover', + status: 'status', + + primary: ['enable', 'disable', 'demote', 'secondary-token', 'revoke-secondary'], + + secondary: ['enable', 'disable', 'promote', 'update-primary'], +}; + +const REPLICATION_MODES = ['dr', 'performance']; +export default ApplicationAdapter.extend({ + version: inject.service(), + shouldBackgroundReloadRecord() { + return true; + }, + findRecord(store, type, id, snapshot) { + let fetches = { + health: this.health(), + sealStatus: this.sealStatus().catch(e => e), + }; + if (this.get('version.isEnterprise')) { + fetches.replicationStatus = this.replicationStatus().catch(e => e); + } + return Ember.RSVP.hash(fetches).then(({ health, sealStatus, replicationStatus }) => { + let ret = { + id, + name: snapshot.attr('name'), + }; + ret = Ember.assign(ret, health); + if (sealStatus instanceof AdapterError === false) { + ret = Ember.assign(ret, { nodes: [sealStatus] }); + } + if (replicationStatus && replicationStatus instanceof AdapterError === false) { + ret = Ember.assign(ret, replicationStatus.data); + } + return Ember.RSVP.resolve(ret); + }); + }, + + pathForType(type) { + return type === 'cluster' ? 'clusters' : Ember.String.pluralize(type); + }, + + health() { + return this.ajax(this.urlFor('health'), 'GET', { + data: { standbycode: 200, sealedcode: 200, uninitcode: 200, drsecondarycode: 200 }, + unauthenticated: true, + }); + }, + + features() { + return this.ajax(`${this.buildURL()}/license/features`, 'GET', { + unauthenticated: true, + }); + }, + + sealStatus() { + return this.ajax(this.urlFor('seal-status'), 'GET', { unauthenticated: true }); + }, + + seal() { + return this.ajax(this.urlFor('seal'), 'PUT'); + }, + + unseal(data) { + return this.ajax(this.urlFor('unseal'), 'PUT', { + data, + unauthenticated: true, + }); + }, + + initCluster(data) { + return this.ajax(this.urlFor('init'), 'PUT', { + data, + unauthenticated: true, + }); + }, + + authenticate({ backend, data }) { + const { token, password, username, path } = data; + const url = this.urlForAuth(backend, username, path); + const verb = backend === 'token' ? 'GET' : 'POST'; + let options = { + unauthenticated: true, + }; + if (backend === 'token') { + options.headers = { + 'X-Vault-Token': token, + }; + } else { + options.data = token ? { token, password } : { password }; + } + + return this.ajax(url, verb, options); + }, + + urlFor(endpoint) { + if (!ENDPOINTS.includes(endpoint)) { + throw new Error( + `Calls to a ${endpoint} endpoint are not currently allowed in the vault cluster adapater` + ); + } + return `${this.buildURL()}/${endpoint}`; + }, + + urlForAuth(type, username, path) { + const authBackend = type.toLowerCase(); + const authURLs = { + github: 'login', + userpass: `login/${encodeURIComponent(username)}`, + ldap: `login/${encodeURIComponent(username)}`, + okta: `login/${encodeURIComponent(username)}`, + token: 'lookup-self', + }; + const urlSuffix = authURLs[authBackend]; + const urlPrefix = path && authBackend !== 'token' ? path : authBackend; + if (!urlSuffix) { + throw new Error(`There is no auth url for ${type}.`); + } + return `/v1/auth/${urlPrefix}/${urlSuffix}`; + }, + + urlForReplication(replicationMode, clusterMode, endpoint) { + let suffix; + const errString = `Calls to replication ${endpoint} endpoint are not currently allowed in the vault cluster adapater`; + if (clusterMode) { + assert(errString, REPLICATION_ENDPOINTS[clusterMode].includes(endpoint)); + suffix = `${replicationMode}/${clusterMode}/${endpoint}`; + } else { + assert(errString, REPLICATION_ENDPOINTS[endpoint]); + suffix = `${endpoint}`; + } + return `${this.buildURL()}/replication/${suffix}`; + }, + + replicationStatus() { + return this.ajax(`${this.buildURL()}/replication/status`, 'GET', { unauthenticated: true }); + }, + + replicationDrPromote(data, options) { + const verb = options && options.checkStatus ? 'GET' : 'PUT'; + return this.ajax(`${this.buildURL()}/replication/dr/secondary/promote`, verb, { + data, + unauthenticated: true, + }); + }, + + generateDrOperationToken(data, options) { + const verb = options && options.checkStatus ? 'GET' : 'PUT'; + let url = `${this.buildURL()}/replication/dr/secondary/generate-operation-token/`; + if (!data || data.pgp_key || data.otp) { + // start the generation + url = url + 'attempt'; + } else { + // progress the operation + url = url + 'update'; + } + return this.ajax(url, verb, { + data, + unauthenticated: true, + }); + }, + + replicationAction(action, replicationMode, clusterMode, data) { + assert( + `${replicationMode} is an unsupported replication mode.`, + replicationMode && REPLICATION_MODES.includes(replicationMode) + ); + + const url = + action === 'recover' || action === 'reindex' + ? this.urlForReplication(replicationMode, null, action) + : this.urlForReplication(replicationMode, clusterMode, action); + + return this.ajax(url, 'POST', { data }); + }, +}); diff --git a/ui/app/adapters/iam-credential.js b/ui/app/adapters/iam-credential.js new file mode 100644 index 000000000..fbd90a4cf --- /dev/null +++ b/ui/app/adapters/iam-credential.js @@ -0,0 +1,26 @@ +import ApplicationAdapter from './application'; + +export default ApplicationAdapter.extend({ + namespace: 'v1', + + url(role, isSTS) { + if (isSTS) { + return `/v1/${role.backend}/sts/${role.name}`; + } + return `/v1/${role.backend}/creds/${role.name}`; + }, + + createRecord(store, type, snapshot) { + const isSTS = snapshot.attr('withSTS'); + const options = isSTS ? { data: { ttl: snapshot.attr('ttl') } } : {}; + const method = isSTS ? 'POST' : 'GET'; + const role = snapshot.attr('role'); + const url = this.url(role, isSTS); + + return this.ajax(url, method, options).then(response => { + response.id = snapshot.id; + response.modelName = type.modelName; + store.pushPayload(type.modelName, response); + }); + }, +}); diff --git a/ui/app/adapters/identity/base.js b/ui/app/adapters/identity/base.js new file mode 100644 index 000000000..6a6ed9fd2 --- /dev/null +++ b/ui/app/adapters/identity/base.js @@ -0,0 +1,23 @@ +import ApplicationAdapater from '../application'; + +export default ApplicationAdapater.extend({ + namespace: 'v1', + pathForType(type) { + return type; + }, + + urlForQuery() { + return this._super(...arguments) + '?list=true'; + }, + + query(store, type) { + return this.ajax(this.buildURL(type.modelName, null, null, 'query'), 'GET'); + }, + + buildURL(modelName, id, snapshot, requestType, query) { + if (requestType === 'createRecord') { + return this._super(...arguments); + } + return this._super(`${modelName}/id`, id, snapshot, requestType, query); + }, +}); diff --git a/ui/app/adapters/identity/entity-alias.js b/ui/app/adapters/identity/entity-alias.js new file mode 100644 index 000000000..5f9b19fc1 --- /dev/null +++ b/ui/app/adapters/identity/entity-alias.js @@ -0,0 +1,3 @@ +import IdentityAdapter from './base'; + +export default IdentityAdapter.extend(); diff --git a/ui/app/adapters/identity/entity-merge.js b/ui/app/adapters/identity/entity-merge.js new file mode 100644 index 000000000..f087244c5 --- /dev/null +++ b/ui/app/adapters/identity/entity-merge.js @@ -0,0 +1,17 @@ +import IdentityAdapter from './base'; + +export default IdentityAdapter.extend({ + buildURL() { + // first arg is modelName which we're hardcoding in the call to _super. + let [, ...args] = arguments; + return this._super('identity/entity/merge', ...args); + }, + + createRecord(store, type, snapshot) { + return this._super(...arguments).then(() => { + // return the `to` id here so we can redirect to it on success + // (and because ember _loves_ 204s for createRecord) + return { id: snapshot.attr('toEntityId') }; + }); + }, +}); diff --git a/ui/app/adapters/identity/entity.js b/ui/app/adapters/identity/entity.js new file mode 100644 index 000000000..7aecbfcd9 --- /dev/null +++ b/ui/app/adapters/identity/entity.js @@ -0,0 +1,18 @@ +import IdentityAdapter from './base'; + +export default IdentityAdapter.extend({ + lookup(store, data) { + let url = `/${this.urlPrefix()}/identity/lookup/entity`; + return this.ajax(url, 'POST', { data }).then(response => { + // unsuccessful lookup is a 204 + if (!response) return; + let modelName = 'identity/entity'; + store.push( + store + .serializerFor(modelName) + .normalizeResponse(store, store.modelFor(modelName), response, response.data.id, 'findRecord') + ); + return response; + }); + }, +}); diff --git a/ui/app/adapters/identity/group-alias.js b/ui/app/adapters/identity/group-alias.js new file mode 100644 index 000000000..5f9b19fc1 --- /dev/null +++ b/ui/app/adapters/identity/group-alias.js @@ -0,0 +1,3 @@ +import IdentityAdapter from './base'; + +export default IdentityAdapter.extend(); diff --git a/ui/app/adapters/identity/group.js b/ui/app/adapters/identity/group.js new file mode 100644 index 000000000..bd12d8ceb --- /dev/null +++ b/ui/app/adapters/identity/group.js @@ -0,0 +1,18 @@ +import IdentityAdapter from './base'; + +export default IdentityAdapter.extend({ + lookup(store, data) { + let url = `/${this.urlPrefix()}/identity/lookup/group`; + return this.ajax(url, 'POST', { data }).then(response => { + // unsuccessful lookup is a 204 + if (!response) return; + let modelName = 'identity/group'; + store.push( + store + .serializerFor(modelName) + .normalizeResponse(store, store.modelFor(modelName), response, response.data.id, 'findRecord') + ); + return response; + }); + }, +}); diff --git a/ui/app/adapters/lease.js b/ui/app/adapters/lease.js new file mode 100644 index 000000000..4b2044159 --- /dev/null +++ b/ui/app/adapters/lease.js @@ -0,0 +1,57 @@ +import ApplicationAdapter from './application'; + +export default ApplicationAdapter.extend({ + revokePrefix(prefix) { + let url = this.buildURL() + '/leases/revoke-prefix/' + prefix; + url = url.replace(/\/$/, ''); + return this.ajax(url, 'PUT'); + }, + + forceRevokePrefix(prefix) { + let url = this.buildURL() + '/leases/revoke-prefix/' + prefix; + url = url.replace(/\/$/, ''); + return this.ajax(url, 'PUT'); + }, + + renew(lease_id, interval) { + let url = this.buildURL() + '/leases/renew'; + return this.ajax(url, 'PUT', { + data: { + lease_id, + interval, + }, + }); + }, + + deleteRecord(store, type, snapshot) { + const lease_id = snapshot.id; + return this.ajax(this.buildURL() + '/leases/revoke', 'PUT', { + data: { + lease_id, + }, + }); + }, + + queryRecord(store, type, query) { + const { lease_id } = query; + return this.ajax(this.buildURL() + '/leases/lookup', 'PUT', { + data: { + lease_id, + }, + }); + }, + + query(store, type, query) { + const prefix = query.prefix || ''; + return this.ajax(this.buildURL() + '/leases/lookup/' + prefix, 'GET', { + data: { + list: true, + }, + }).then(resp => { + if (prefix) { + resp.prefix = prefix; + } + return resp; + }); + }, +}); diff --git a/ui/app/adapters/mount-filter-config.js b/ui/app/adapters/mount-filter-config.js new file mode 100644 index 000000000..597eedb0d --- /dev/null +++ b/ui/app/adapters/mount-filter-config.js @@ -0,0 +1,28 @@ +import ApplicationAdapter from './application'; + +export default ApplicationAdapter.extend({ + url(id) { + return `${this.buildURL()}/replication/performance/primary/mount-filter/${id}`; + }, + + findRecord(store, type, id) { + return this.ajax(this.url(id), 'GET').then(resp => { + resp.id = id; + return resp; + }); + }, + + createRecord(store, type, snapshot) { + return this.ajax(this.url(snapshot.id), 'PUT', { + data: this.serialize(snapshot), + }); + }, + + updateRecord() { + return this.createRecord(...arguments); + }, + + deleteRecord(store, type, snapshot) { + return this.ajax(this.url(snapshot.id), 'DELETE'); + }, +}); diff --git a/ui/app/adapters/node.js b/ui/app/adapters/node.js new file mode 100644 index 000000000..c0a6b722e --- /dev/null +++ b/ui/app/adapters/node.js @@ -0,0 +1,3 @@ +import ApplicationAdapter from './application'; + +export default ApplicationAdapter.extend(); diff --git a/ui/app/adapters/pki-ca-certificate-sign.js b/ui/app/adapters/pki-ca-certificate-sign.js new file mode 100644 index 000000000..fedb0b7d6 --- /dev/null +++ b/ui/app/adapters/pki-ca-certificate-sign.js @@ -0,0 +1,8 @@ +import Adapter from './pki'; + +export default Adapter.extend({ + url(_, snapshot) { + const backend = snapshot.attr('backend'); + return `/v1/${backend}/root/sign-intermediate`; + }, +}); diff --git a/ui/app/adapters/pki-ca-certificate.js b/ui/app/adapters/pki-ca-certificate.js new file mode 100644 index 000000000..f9910220a --- /dev/null +++ b/ui/app/adapters/pki-ca-certificate.js @@ -0,0 +1,62 @@ +import ApplicationAdapter from './application'; + +export default ApplicationAdapter.extend({ + namespace: 'v1', + + defaultSerializer: 'ssh', + + url(snapshot, action) { + const { backend, caType, type } = snapshot.attributes(); + if (action === 'sign-intermediate') { + return `/v1/${backend}/root/sign-intermediate`; + } + if (action === 'set-signed-intermediate') { + return `/v1/${backend}/intermediate/set-signed`; + } + if (action === 'upload') { + return `/v1/${backend}/config/ca`; + } + return `/v1/${backend}/${caType}/generate/${type}`; + }, + + createRecordOrUpdate(store, type, snapshot, requestType) { + const serializer = store.serializerFor(this.get('defaultSerializer')); + const isUpload = snapshot.attr('uploadPemBundle'); + const isSetSignedIntermediate = snapshot.adapterOptions.method === 'setSignedIntermediate'; + let action = snapshot.adapterOptions.method === 'signIntermediate' ? 'sign-intermediate' : null; + let data; + if (isUpload) { + action = 'upload'; + data = { pem_bundle: snapshot.attr('pemBundle') }; + } else if (isSetSignedIntermediate) { + action = 'set-signed-intermediate'; + data = { certificate: snapshot.attr('certificate') }; + } else { + data = serializer.serialize(snapshot, requestType); + } + + return this.ajax(this.url(snapshot, action), 'POST', { data }).then(response => { + // uploading CA, setting signed intermediate cert, and attempting to generate + // a new CA if one exists, all return a 204 + if (!response) { + response = {}; + } + response.id = snapshot.id; + response.modelName = type.modelName; + store.pushPayload(type.modelName, response); + }); + }, + + createRecord() { + return this.createRecordOrUpdate(...arguments); + }, + + updateRecord() { + return this.createRecordOrUpdate(...arguments); + }, + + deleteRecord(store, type, snapshot) { + const backend = snapshot.attr('backend'); + return this.ajax(`/v1/${backend}/root`, 'DELETE'); + }, +}); diff --git a/ui/app/adapters/pki-certificate-sign.js b/ui/app/adapters/pki-certificate-sign.js new file mode 100644 index 000000000..eb5ca2615 --- /dev/null +++ b/ui/app/adapters/pki-certificate-sign.js @@ -0,0 +1,10 @@ +import Adapter from './pki'; + +export default Adapter.extend({ + url(role, snapshot) { + if (snapshot.attr('signVerbatim') === true) { + return `/v1/${role.backend}/sign-verbatim/${role.name}`; + } + return `/v1/${role.backend}/sign/${role.name}`; + }, +}); diff --git a/ui/app/adapters/pki-certificate.js b/ui/app/adapters/pki-certificate.js new file mode 100644 index 000000000..8ec9553b2 --- /dev/null +++ b/ui/app/adapters/pki-certificate.js @@ -0,0 +1,66 @@ +import Ember from 'ember'; +import Adapter from './pki'; + +export default Adapter.extend({ + url(role) { + return `/v1/${role.backend}/issue/${role.name}`; + }, + + urlFor(backend, id) { + let url = `${this.buildURL()}/${backend}/certs`; + if (id) { + url = `${this.buildURL()}/${backend}/cert/${id}`; + } + return url; + }, + + optionsForQuery(id) { + let data = {}; + if (!id) { + data['list'] = true; + } + return { data }; + }, + + fetchByQuery(store, query) { + const { backend, id } = query; + return this.ajax(this.urlFor(backend, id), 'GET', this.optionsForQuery(id)).then(resp => { + const data = { + backend, + }; + if (id) { + data.serial_number = id; + data.id = id; + data.id_for_nav = `cert/${id}`; + } + return Ember.assign({}, resp, data); + }); + }, + + query(store, type, query) { + return this.fetchByQuery(store, query); + }, + + queryRecord(store, type, query) { + return this.fetchByQuery(store, query); + }, + + updateRecord(store, type, snapshot) { + if (snapshot.adapterOptions.method !== 'revoke') { + return; + } + const id = snapshot.id; + const backend = snapshot.record.get('backend'); + const data = { + serial_number: id, + }; + return this.ajax(`${this.buildURL()}/${backend}/revoke`, 'POST', { data }).then(resp => { + const data = { + id, + serial_number: id, + backend, + }; + return Ember.assign({}, resp, data); + }); + }, +}); diff --git a/ui/app/adapters/pki-config.js b/ui/app/adapters/pki-config.js new file mode 100644 index 000000000..157274471 --- /dev/null +++ b/ui/app/adapters/pki-config.js @@ -0,0 +1,122 @@ +import ApplicationAdapter from './application'; +import DS from 'ember-data'; +import Ember from 'ember'; + +export default ApplicationAdapter.extend({ + namespace: 'v1', + + defaultSerializer: 'config', + + urlFor(backend, section) { + const urls = { + tidy: `/v1/${backend}/tidy`, + urls: `/v1/${backend}/config/urls`, + crl: `/v1/${backend}/config/crl`, + }; + return urls[section]; + }, + + createOrUpdate(store, type, snapshot) { + const url = this.urlFor(snapshot.record.get('backend'), snapshot.adapterOptions.method); + const serializer = store.serializerFor(this.get('defaultSerializer')); + if (!url) { + return; + } + const data = snapshot.adapterOptions.fields.reduce((data, field) => { + let attr = snapshot.attr(field); + if (attr) { + serializer.serializeAttribute(snapshot, data, field, attr); + } else { + data[serializer.keyForAttribute(field)] = attr; + } + return data; + }, {}); + return this.ajax(url, 'POST', { data }); + }, + + createRecord() { + return this.createOrUpdate(...arguments); + }, + + updateRecord() { + return this.createOrUpdate(...arguments, 'update'); + }, + + fetchSection(backendPath, section) { + const sections = ['cert', 'urls', 'crl', 'tidy']; + if (!section || !sections.includes(section)) { + const error = new DS.AdapterError(); + Ember.set(error, 'httpStatus', 404); + throw error; + } + return this[`fetch${Ember.String.capitalize(section)}`](backendPath); + }, + + id(backendPath) { + return backendPath + '-config-ca'; + }, + + fetchCert(backendPath) { + // these are all un-authed so using `fetch` directly works + const derURL = `/v1/${backendPath}/ca`; + const pemURL = `${derURL}/pem`; + const chainURL = `${derURL}_chain`; + + return Ember.RSVP.hash({ + backend: backendPath, + id: this.id(backendPath), + der: this.rawRequest(derURL, { unauthenticate: true }).then(response => response.blob()), + pem: this.rawRequest(pemURL, { unauthenticate: true }).then(response => response.text()), + ca_chain: this.rawRequest(chainURL, { unauthenticate: true }).then(response => response.text()), + }); + }, + + fetchUrls(backendPath) { + const url = `/v1/${backendPath}/config/urls`; + const id = this.id(backendPath); + return this.ajax(url, 'GET') + .then(resp => { + resp.id = id; + resp.backend = backendPath; + return resp; + }) + .catch(e => { + if (e.httpStatus === 404) { + return Ember.RSVP.resolve({ id }); + } else { + throw e; + } + }); + }, + + fetchCrl(backendPath) { + const url = `/v1/${backendPath}/config/crl`; + const id = this.id(backendPath); + return this.ajax(url, 'GET') + .then(resp => { + resp.id = id; + resp.backend = backendPath; + return resp; + }) + .catch(e => { + if (e.httpStatus === 404) { + return { id }; + } else { + throw e; + } + }); + }, + + fetchTidy(backendPath) { + const id = this.id(backendPath); + return Ember.RSVP.resolve({ id, backend: backendPath }); + }, + + queryRecord(store, type, query) { + const { backend, section } = query; + return this.fetchSection(backend, section).then(resp => { + resp.backend = backend; + return resp; + }); + }, +}); diff --git a/ui/app/adapters/pki.js b/ui/app/adapters/pki.js new file mode 100644 index 000000000..bcebb8273 --- /dev/null +++ b/ui/app/adapters/pki.js @@ -0,0 +1,24 @@ +import ApplicationAdapter from './application'; +import Ember from 'ember'; + +export default ApplicationAdapter.extend({ + namespace: 'v1', + + defaultSerializer: 'ssh', + + url(/*role*/) { + Ember.assert('Override the `url` method to extend the SSH adapter', false); + }, + + createRecord(store, type, snapshot, requestType) { + const serializer = store.serializerFor(type.modelName); + const data = serializer.serialize(snapshot, requestType); + const role = snapshot.attr('role'); + + return this.ajax(this.url(role, snapshot), 'POST', { data }).then(response => { + response.id = snapshot.id; + response.modelName = type.modelName; + store.pushPayload(type.modelName, response); + }); + }, +}); diff --git a/ui/app/adapters/policy.js b/ui/app/adapters/policy.js new file mode 100644 index 000000000..5846e31d9 --- /dev/null +++ b/ui/app/adapters/policy.js @@ -0,0 +1,35 @@ +import Ember from 'ember'; +import ApplicationAdapter from './application'; + +export default ApplicationAdapter.extend({ + namespace: 'v1/sys', + pathForType(type) { + let path = type.replace('policy', 'policies'); + return path; + }, + + createOrUpdate(store, type, snapshot) { + const serializer = store.serializerFor('policy'); + const data = serializer.serialize(snapshot); + const name = snapshot.attr('name'); + + return this.ajax(this.buildURL(type.modelName, name), 'PUT', { data }).then(() => { + // doing this to make it like a Vault response - ember data doesn't like 204s if it's not a DELETE + return { + data: Ember.assign({}, snapshot.record.toJSON(), { id: name }), + }; + }); + }, + + createRecord() { + return this.createOrUpdate(...arguments); + }, + + updateRecord() { + return this.createOrUpdate(...arguments); + }, + + query(store, type) { + return this.ajax(this.buildURL(type.modelName), 'GET', { data: { list: true } }); + }, +}); diff --git a/ui/app/adapters/policy/acl.js b/ui/app/adapters/policy/acl.js new file mode 100644 index 000000000..ea0f1a321 --- /dev/null +++ b/ui/app/adapters/policy/acl.js @@ -0,0 +1,3 @@ +import PolicyAdapter from '../policy'; + +export default PolicyAdapter.extend(); diff --git a/ui/app/adapters/policy/egp.js b/ui/app/adapters/policy/egp.js new file mode 100644 index 000000000..ea0f1a321 --- /dev/null +++ b/ui/app/adapters/policy/egp.js @@ -0,0 +1,3 @@ +import PolicyAdapter from '../policy'; + +export default PolicyAdapter.extend(); diff --git a/ui/app/adapters/policy/rgp.js b/ui/app/adapters/policy/rgp.js new file mode 100644 index 000000000..ea0f1a321 --- /dev/null +++ b/ui/app/adapters/policy/rgp.js @@ -0,0 +1,3 @@ +import PolicyAdapter from '../policy'; + +export default PolicyAdapter.extend(); diff --git a/ui/app/adapters/role-aws.js b/ui/app/adapters/role-aws.js new file mode 100644 index 000000000..67f50ef77 --- /dev/null +++ b/ui/app/adapters/role-aws.js @@ -0,0 +1,69 @@ +import ApplicationAdapter from './application'; +import Ember from 'ember'; + +export default ApplicationAdapter.extend({ + namespace: 'v1', + + createOrUpdate(store, type, snapshot, requestType) { + const serializer = store.serializerFor(type.modelName); + const data = serializer.serialize(snapshot, requestType); + const { id } = snapshot; + let url = this.urlForRole(snapshot.record.get('backend'), id); + + return this.ajax(url, 'POST', { data }); + }, + + createRecord() { + return this.createOrUpdate(...arguments); + }, + + updateRecord() { + return this.createOrUpdate(...arguments, 'update'); + }, + + deleteRecord(store, type, snapshot) { + const { id } = snapshot; + return this.ajax(this.urlForRole(snapshot.record.get('backend'), id), 'DELETE'); + }, + + pathForType() { + return 'roles'; + }, + + urlForRole(backend, id) { + let url = `${this.buildURL()}/${backend}/roles`; + if (id) { + url = url + '/' + id; + } + return url; + }, + + optionsForQuery(id) { + let data = {}; + if (!id) { + data['list'] = true; + } + return { data }; + }, + + fetchByQuery(store, query) { + const { id, backend } = query; + return this.ajax(this.urlForRole(backend, id), 'GET', this.optionsForQuery(id)).then(resp => { + const data = { + id, + name: id, + backend, + }; + + return Ember.assign({}, resp, data); + }); + }, + + query(store, type, query) { + return this.fetchByQuery(store, query); + }, + + queryRecord(store, type, query) { + return this.fetchByQuery(store, query); + }, +}); diff --git a/ui/app/adapters/role-pki.js b/ui/app/adapters/role-pki.js new file mode 100644 index 000000000..67f50ef77 --- /dev/null +++ b/ui/app/adapters/role-pki.js @@ -0,0 +1,69 @@ +import ApplicationAdapter from './application'; +import Ember from 'ember'; + +export default ApplicationAdapter.extend({ + namespace: 'v1', + + createOrUpdate(store, type, snapshot, requestType) { + const serializer = store.serializerFor(type.modelName); + const data = serializer.serialize(snapshot, requestType); + const { id } = snapshot; + let url = this.urlForRole(snapshot.record.get('backend'), id); + + return this.ajax(url, 'POST', { data }); + }, + + createRecord() { + return this.createOrUpdate(...arguments); + }, + + updateRecord() { + return this.createOrUpdate(...arguments, 'update'); + }, + + deleteRecord(store, type, snapshot) { + const { id } = snapshot; + return this.ajax(this.urlForRole(snapshot.record.get('backend'), id), 'DELETE'); + }, + + pathForType() { + return 'roles'; + }, + + urlForRole(backend, id) { + let url = `${this.buildURL()}/${backend}/roles`; + if (id) { + url = url + '/' + id; + } + return url; + }, + + optionsForQuery(id) { + let data = {}; + if (!id) { + data['list'] = true; + } + return { data }; + }, + + fetchByQuery(store, query) { + const { id, backend } = query; + return this.ajax(this.urlForRole(backend, id), 'GET', this.optionsForQuery(id)).then(resp => { + const data = { + id, + name: id, + backend, + }; + + return Ember.assign({}, resp, data); + }); + }, + + query(store, type, query) { + return this.fetchByQuery(store, query); + }, + + queryRecord(store, type, query) { + return this.fetchByQuery(store, query); + }, +}); diff --git a/ui/app/adapters/role-ssh.js b/ui/app/adapters/role-ssh.js new file mode 100644 index 000000000..6f33ec4d3 --- /dev/null +++ b/ui/app/adapters/role-ssh.js @@ -0,0 +1,96 @@ +import ApplicationAdapter from './application'; +import Ember from 'ember'; + +export default ApplicationAdapter.extend({ + namespace: 'v1', + + defaultSerializer: 'role', + + createOrUpdate(store, type, snapshot, requestType) { + const serializer = store.serializerFor(type.modelName); + const data = serializer.serialize(snapshot, requestType); + const { id } = snapshot; + let url = this.urlForRole(snapshot.record.get('backend'), id); + + return this.ajax(url, 'POST', { data }); + }, + + createRecord() { + return this.createOrUpdate(...arguments); + }, + + updateRecord() { + return this.createOrUpdate(...arguments, 'update'); + }, + + deleteRecord(store, type, snapshot) { + const { id } = snapshot; + return this.ajax(this.urlForRole(snapshot.record.get('backend'), id), 'DELETE'); + }, + + pathForType() { + return 'roles'; + }, + + urlForRole(backend, id) { + let url = `${this.buildURL()}/${backend}/roles`; + if (id) { + url = url + '/' + id; + } + return url; + }, + + optionsForQuery(id) { + let data = {}; + if (!id) { + data['list'] = true; + } + return { data }; + }, + + fetchByQuery(store, query) { + const { id, backend } = query; + let zeroAddressAjax = Ember.RSVP.resolve(); + const queryAjax = this.ajax(this.urlForRole(backend, id), 'GET', this.optionsForQuery(id)); + if (!id) { + zeroAddressAjax = this.findAllZeroAddress(store, query); + } + + return Ember.RSVP.allSettled([queryAjax, zeroAddressAjax]).then(results => { + // query result 404d, so throw the adapterError + if (!results[0].value) { + throw results[0].reason; + } + let resp = { + id, + name: id, + backend, + }; + + results.forEach(result => { + if (result.value) { + if (result.value.data.roles) { + resp = Ember.assign({}, resp, { zero_address_roles: result.value.data.roles }); + } else { + resp = Ember.assign({}, resp, result.value); + } + } + }); + return resp; + }); + }, + + findAllZeroAddress(store, query) { + const { backend } = query; + const url = `/v1/${backend}/config/zeroaddress`; + return this.ajax(url, 'GET'); + }, + + query(store, type, query) { + return this.fetchByQuery(store, query); + }, + + queryRecord(store, type, query) { + return this.fetchByQuery(store, query); + }, +}); diff --git a/ui/app/adapters/secret-cubbyhole.js b/ui/app/adapters/secret-cubbyhole.js new file mode 100644 index 000000000..389f10783 --- /dev/null +++ b/ui/app/adapters/secret-cubbyhole.js @@ -0,0 +1,61 @@ +import Ember from 'ember'; +import ApplicationAdapter from './application'; + +export default ApplicationAdapter.extend({ + namespace: 'v1', + + createOrUpdate(store, type, snapshot) { + const serializer = store.serializerFor(type.modelName); + const data = serializer.serialize(snapshot); + const { id } = snapshot; + + return this.ajax(this.urlForSecret(snapshot.attr('backend'), id), 'POST', { data }); + }, + + createRecord() { + return this.createOrUpdate(...arguments); + }, + + updateRecord() { + return this.createOrUpdate(...arguments); + }, + + deleteRecord(store, type, snapshot) { + const { id } = snapshot; + return this.ajax(this.urlForSecret(snapshot.attr('backend'), id), 'DELETE'); + }, + + urlForSecret(backend, id) { + let url = this.buildURL() + '/' + backend + '/'; + if (!Ember.isEmpty(id)) { + url = url + id; + } + + return url; + }, + + optionsForQuery(id, action) { + let data = {}; + if (action === 'query') { + data['list'] = true; + } + + return { data }; + }, + + fetchByQuery(query, action) { + const { id, backend } = query; + return this.ajax(this.urlForSecret(backend, id), 'GET', this.optionsForQuery(id, action)).then(resp => { + resp.id = id; + return resp; + }); + }, + + query(store, type, query) { + return this.fetchByQuery(query, 'query'); + }, + + queryRecord(store, type, query) { + return this.fetchByQuery(query, 'queryRecord'); + }, +}); diff --git a/ui/app/adapters/secret-engine.js b/ui/app/adapters/secret-engine.js new file mode 100644 index 000000000..4c483f990 --- /dev/null +++ b/ui/app/adapters/secret-engine.js @@ -0,0 +1,102 @@ +import Ember from 'ember'; +import ApplicationAdapter from './application'; +import DS from 'ember-data'; + +export default ApplicationAdapter.extend({ + url(path) { + const url = `${this.buildURL()}/mounts`; + return path ? url + '/' + path : url; + }, + + pathForType(type) { + let path; + switch (type) { + case 'cluster': + path = 'clusters'; + break; + case 'secret-engine': + path = 'mounts'; + break; + default: + path = Ember.String.pluralize(type); + break; + } + return path; + }, + + query() { + return this.ajax(this.url(), 'GET').catch(e => { + if (e instanceof DS.AdapterError) { + Ember.set(e, 'policyPath', 'sys/mounts'); + } + + throw e; + }); + }, + + createRecord(store, type, snapshot) { + const serializer = store.serializerFor(type.modelName); + const data = serializer.serialize(snapshot); + const path = snapshot.attr('path'); + + return this.ajax(this.url(path), 'POST', { data }).then(() => { + // ember data doesn't like 204s if it's not a DELETE + return { + data: Ember.assign({}, data, { path: path + '/', id: path }), + }; + }); + }, + + findRecord(store, type, path, snapshot) { + if (snapshot.attr('type') === 'ssh') { + return this.ajax(`/v1/${path}/config/ca`, 'GET'); + } + return; + }, + + queryRecord(store, type, query) { + if (query.type === 'aws') { + return this.ajax(`/v1/${query.backend}/config/lease`, 'GET').then(resp => { + resp.path = query.backend + '/'; + return resp; + }); + } + return; + }, + + updateRecord(store, type, snapshot) { + const { apiPath, options, adapterMethod } = snapshot.adapterOptions; + if (adapterMethod) { + return this[adapterMethod](...arguments); + } + if (apiPath) { + const serializer = store.serializerFor(type.modelName); + const data = serializer.serialize(snapshot); + const path = snapshot.id; + return this.ajax(`/v1/${path}/${apiPath}`, options.isDelete ? 'DELETE' : 'POST', { data }); + } + }, + + saveAWSRoot(store, type, snapshot) { + let { data } = snapshot.adapterOptions; + const path = snapshot.id; + return this.ajax(`/v1/${path}/config/root`, 'POST', { data }); + }, + + saveAWSLease(store, type, snapshot) { + let { data } = snapshot.adapterOptions; + const path = snapshot.id; + return this.ajax(`/v1/${path}/config/lease`, 'POST', { data }); + }, + + saveZeroAddressConfig(store, type, snapshot) { + const path = snapshot.id; + const roles = store.peekAll('role-ssh').filterBy('zeroAddress').mapBy('id').join(','); + const url = `/v1/${path}/config/zeroaddress`; + const data = { roles }; + if (roles === '') { + return this.ajax(url, 'DELETE'); + } + return this.ajax(url, 'POST', { data }); + }, +}); diff --git a/ui/app/adapters/secret.js b/ui/app/adapters/secret.js new file mode 100644 index 000000000..d056dc58a --- /dev/null +++ b/ui/app/adapters/secret.js @@ -0,0 +1,86 @@ +import Ember from 'ember'; +import ApplicationAdapter from './application'; +const { computed } = Ember; + +export default ApplicationAdapter.extend({ + namespace: 'v1', + + headers: computed(function() { + return { + 'X-Vault-Kv-Client': 'v1', + }; + }), + + createOrUpdate(store, type, snapshot) { + const serializer = store.serializerFor(type.modelName); + const data = serializer.serialize(snapshot); + const { id } = snapshot; + + return this.ajax(this.urlForSecret(snapshot.attr('backend'), id), 'POST', { + data: { data }, + }); + }, + + createRecord() { + return this.createOrUpdate(...arguments); + }, + + updateRecord() { + return this.createOrUpdate(...arguments); + }, + + deleteRecord(store, type, snapshot) { + const { id } = snapshot; + return this.ajax(this.urlForSecret(snapshot.attr('backend'), id), 'DELETE'); + }, + + urlForSecret(backend, id, infix = 'data') { + let url = `${this.buildURL()}/${backend}/${infix}/`; + if (!Ember.isEmpty(id)) { + url = url + id; + } + + return url; + }, + + optionsForQuery(id, action) { + let data = {}; + if (action === 'query') { + data['list'] = true; + } + + return { data }; + }, + + urlForQuery(query) { + let { id, backend } = query; + return this.urlForSecret(backend, id, 'metadata'); + }, + + urlForQueryRecord(query) { + let { id, backend } = query; + return this.urlForSecret(backend, id); + }, + + query(store, type, query) { + return this.ajax( + this.urlForQuery(query, type.modelName), + 'GET', + this.optionsForQuery(query.id, 'query') + ).then(resp => { + resp.id = query.id; + return resp; + }); + }, + + queryRecord(store, type, query) { + return this.ajax( + this.urlForQueryRecord(query, type.modelName), + 'GET', + this.optionsForQuery(query.id, 'queryRecord') + ).then(resp => { + resp.id = query.id; + return resp; + }); + }, +}); diff --git a/ui/app/adapters/ssh-otp-credential.js b/ui/app/adapters/ssh-otp-credential.js new file mode 100644 index 000000000..99b13e38a --- /dev/null +++ b/ui/app/adapters/ssh-otp-credential.js @@ -0,0 +1,7 @@ +import SSHAdapter from './ssh'; + +export default SSHAdapter.extend({ + url(role) { + return `/v1/${role.backend}/creds/${role.name}`; + }, +}); diff --git a/ui/app/adapters/ssh-sign.js b/ui/app/adapters/ssh-sign.js new file mode 100644 index 000000000..a5e8cbdcd --- /dev/null +++ b/ui/app/adapters/ssh-sign.js @@ -0,0 +1,7 @@ +import SSHAdapter from './ssh'; + +export default SSHAdapter.extend({ + url(role) { + return `/v1/${role.backend}/sign/${role.name}`; + }, +}); diff --git a/ui/app/adapters/ssh.js b/ui/app/adapters/ssh.js new file mode 100644 index 000000000..d275a43e6 --- /dev/null +++ b/ui/app/adapters/ssh.js @@ -0,0 +1,24 @@ +import ApplicationAdapter from './application'; +import Ember from 'ember'; + +export default ApplicationAdapter.extend({ + namespace: 'v1', + + defaultSerializer: 'ssh', + + url(/*role*/) { + Ember.assert('Override the `url` method to extend the SSH adapter', false); + }, + + createRecord(store, type, snapshot, requestType) { + const serializer = store.serializerFor(type.modelName); + const data = serializer.serialize(snapshot, requestType); + const role = snapshot.attr('role'); + + return this.ajax(this.url(role), 'POST', { data }).then(response => { + response.id = snapshot.id; + response.modelName = type.modelName; + store.pushPayload(type.modelName, response); + }); + }, +}); diff --git a/ui/app/adapters/tools.js b/ui/app/adapters/tools.js new file mode 100644 index 000000000..0432c756d --- /dev/null +++ b/ui/app/adapters/tools.js @@ -0,0 +1,23 @@ +import ApplicationAdapter from './application'; + +const WRAPPING_ENDPOINTS = ['lookup', 'wrap', 'unwrap', 'rewrap']; +const TOOLS_ENDPOINTS = ['random', 'hash']; + +export default ApplicationAdapter.extend({ + toolUrlFor(action) { + const isWrapping = WRAPPING_ENDPOINTS.includes(action); + const isTool = TOOLS_ENDPOINTS.includes(action); + const prefix = isWrapping ? 'wrapping' : 'tools'; + if (!isWrapping && !isTool) { + throw new Error(`Calls to a ${action} endpoint are not currently allowed in the tool adapter`); + } + return `${this.buildURL()}/${prefix}/${action}`; + }, + + toolAction(action, data, options = {}) { + const { wrapTTL } = options; + const url = this.toolUrlFor(action); + const ajaxOptions = wrapTTL ? { data, wrapTTL } : { data }; + return this.ajax(url, 'POST', ajaxOptions); + }, +}); diff --git a/ui/app/adapters/transit-key.js b/ui/app/adapters/transit-key.js new file mode 100644 index 000000000..520595300 --- /dev/null +++ b/ui/app/adapters/transit-key.js @@ -0,0 +1,114 @@ +import Ember from 'ember'; +import ApplicationAdapter from './application'; + +export default ApplicationAdapter.extend({ + namespace: 'v1', + + createOrUpdate(store, type, snapshot, requestType) { + const serializer = store.serializerFor(type.modelName); + const data = serializer.serialize(snapshot, requestType); + const { id } = snapshot; + let url = this.urlForSecret(snapshot.record.get('backend'), id); + + if (requestType === 'update') { + url = url + '/config'; + } + + return this.ajax(url, 'POST', { data }); + }, + + createRecord() { + return this.createOrUpdate(...arguments); + }, + + updateRecord() { + return this.createOrUpdate(...arguments, 'update'); + }, + + deleteRecord(store, type, snapshot) { + const { id } = snapshot; + return this.ajax(this.urlForSecret(snapshot.record.get('backend'), id), 'DELETE'); + }, + + pathForType(type) { + let path; + switch (type) { + case 'cluster': + path = 'clusters'; + break; + case 'secret-engine': + path = 'secrets'; + break; + default: + path = Ember.String.pluralize(type); + break; + } + return path; + }, + + urlForSecret(backend, id) { + let url = `${this.buildURL()}/${backend}/keys/`; + if (id) { + url += id; + } + return url; + }, + + urlForAction(action, backend, id, param) { + let urlBase = `${this.buildURL()}/${backend}/${action}`; + // these aren't key-specific + if (action === 'hash' || action === 'random') { + return urlBase; + } + if (action === 'datakey' && param) { + // datakey action has `wrapped` or `plaintext` as part of the url + return `${urlBase}/${param}/${id}`; + } + if (action === 'export' && param) { + let [type, version] = param; + const exportBase = `${urlBase}/${type}-key/${id}`; + return version ? `${exportBase}/${version}` : exportBase; + } + return `${urlBase}/${id}`; + }, + + optionsForQuery(id) { + let data = {}; + if (!id) { + data['list'] = true; + } + return { data }; + }, + + fetchByQuery(query) { + const { id, backend } = query; + return this.ajax(this.urlForSecret(backend, id), 'GET', this.optionsForQuery(id)).then(resp => { + resp.id = id; + return resp; + }); + }, + + query(store, type, query) { + return this.fetchByQuery(query); + }, + + queryRecord(store, type, query) { + return this.fetchByQuery(query); + }, + + // rotate, encrypt, decrypt, sign, verify, hmac, rewrap, datakey + keyAction(action, { backend, id, payload }, options = {}) { + const verb = action === 'export' ? 'GET' : 'POST'; + const { wrapTTL } = options; + if (action === 'rotate') { + return this.ajax(this.urlForSecret(backend, id) + '/rotate', verb); + } + const { param } = payload; + + delete payload.param; + return this.ajax(this.urlForAction(action, backend, id, param), verb, { + data: payload, + wrapTTL, + }); + }, +}); diff --git a/ui/app/app.js b/ui/app/app.js new file mode 100644 index 000000000..dde117785 --- /dev/null +++ b/ui/app/app.js @@ -0,0 +1,16 @@ +import Ember from 'ember'; +import Resolver from './resolver'; +import loadInitializers from 'ember-load-initializers'; +import config from './config/environment'; + +let App; + +App = Ember.Application.extend({ + modulePrefix: config.modulePrefix, + podModulePrefix: config.podModulePrefix, + Resolver, +}); + +loadInitializers(App, config.modulePrefix); + +export default App; diff --git a/ui/app/components/.gitkeep b/ui/app/components/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/ui/app/components/auth-config-form/config.js b/ui/app/components/auth-config-form/config.js new file mode 100644 index 000000000..8dee9b840 --- /dev/null +++ b/ui/app/components/auth-config-form/config.js @@ -0,0 +1,32 @@ +import Ember from 'ember'; +import { task } from 'ember-concurrency'; +import DS from 'ember-data'; + +const { inject } = Ember; + +const AuthConfigBase = Ember.Component.extend({ + tagName: '', + model: null, + + flashMessages: inject.service(), + + saveModel: task(function*() { + try { + yield this.get('model').save(); + } catch (err) { + // AdapterErrors are handled by the error-message component + // in the form + if (err instanceof DS.AdapterError === false) { + throw err; + } + return; + } + this.get('flashMessages').success('The configuration was saved successfully.'); + }), +}); + +AuthConfigBase.reopenClass({ + positionalParams: ['model'], +}); + +export default AuthConfigBase; diff --git a/ui/app/components/auth-config-form/options.js b/ui/app/components/auth-config-form/options.js new file mode 100644 index 000000000..73a2dd063 --- /dev/null +++ b/ui/app/components/auth-config-form/options.js @@ -0,0 +1,22 @@ +import AuthConfigComponent from './config'; +import { task } from 'ember-concurrency'; +import DS from 'ember-data'; + +export default AuthConfigComponent.extend({ + saveModel: task(function*() { + const model = this.get('model'); + let data = model.get('config').toJSON(); + data.description = model.get('description'); + try { + yield model.tune(data); + } catch (err) { + // AdapterErrors are handled by the error-message component + // in the form + if (err instanceof DS.AdapterError === false) { + throw err; + } + return; + } + this.get('flashMessages').success('The configuration options were saved successfully.'); + }), +}); diff --git a/ui/app/components/auth-form.js b/ui/app/components/auth-form.js new file mode 100644 index 000000000..a5a95846f --- /dev/null +++ b/ui/app/components/auth-form.js @@ -0,0 +1,63 @@ +import Ember from 'ember'; +import { supportedAuthBackends } from 'vault/helpers/supported-auth-backends'; +const BACKENDS = supportedAuthBackends(); + +export default Ember.Component.extend({ + classNames: ['auth-form'], + routing: Ember.inject.service('-routing'), + auth: Ember.inject.service(), + flashMessages: Ember.inject.service(), + didRender() { + // on very narrow viewports the active tab may be overflowed, so we scroll it into view here + this.$('li.is-active').get(0).scrollIntoView(); + }, + + cluster: null, + redirectTo: null, + + selectedAuthType: 'token', + selectedAuthBackend: Ember.computed('selectedAuthType', function() { + return BACKENDS.findBy('type', this.get('selectedAuthType')); + }), + + providerComponentName: Ember.computed('selectedAuthBackend.type', function() { + const type = Ember.String.dasherize(this.get('selectedAuthBackend.type')); + return `auth-form/${type}`; + }), + + handleError(e) { + this.set('loading', false); + this.set('error', `Authentication failed: ${e.errors.join('.')}`); + }, + + actions: { + doSubmit(data) { + this.setProperties({ + loading: true, + error: null, + }); + const targetRoute = this.get('redirectTo') || 'vault.cluster'; + //const {password, token, username} = data; + const backend = this.get('selectedAuthBackend.type'); + const path = this.get('customPath'); + if (this.get('useCustomPath') && path) { + data.path = path; + } + const clusterId = this.get('cluster.id'); + this.get('auth').authenticate({ clusterId, backend, data }).then( + ({ isRoot }) => { + this.set('loading', false); + const transition = this.get('routing.router').transitionTo(targetRoute); + if (isRoot) { + transition.followRedirects().then(() => { + this.get('flashMessages').warning( + 'You have logged in with a root token. As a security precaution, this root token will not be stored by your browser and you will need to re-authenticate after the window is closed or refreshed.' + ); + }); + } + }, + (...errArgs) => this.handleError(...errArgs) + ); + }, + }, +}); diff --git a/ui/app/components/auth-info.js b/ui/app/components/auth-info.js new file mode 100644 index 000000000..ba44e5c71 --- /dev/null +++ b/ui/app/components/auth-info.js @@ -0,0 +1,32 @@ +import Ember from 'ember'; + +export default Ember.Component.extend({ + auth: Ember.inject.service(), + + routing: Ember.inject.service('-routing'), + + transitionToRoute: function() { + var router = this.get('routing.router'); + router.transitionTo.apply(router, arguments); + }, + + classNames: 'user-menu auth-info', + + isRenewing: Ember.computed.or('fakeRenew', 'auth.isRenewing'), + + actions: { + renewToken() { + this.set('fakeRenew', true); + Ember.run.later(() => { + this.set('fakeRenew', false); + this.get('auth').renew(); + }, 200); + }, + + revokeToken() { + this.get('auth').revokeCurrentToken().then(() => { + this.transitionToRoute('vault.cluster.logout'); + }); + }, + }, +}); diff --git a/ui/app/components/b64-toggle.js b/ui/app/components/b64-toggle.js new file mode 100644 index 000000000..a84995e84 --- /dev/null +++ b/ui/app/components/b64-toggle.js @@ -0,0 +1,128 @@ +import Ember from 'ember'; +import { encodeString, decodeString } from 'vault/utils/b64'; + +const { computed, get, set } = Ember; +const B64 = 'base64'; +const UTF8 = 'utf-8'; +export default Ember.Component.extend({ + tagName: 'button', + attributeBindings: ['type'], + type: 'button', + classNames: ['button', 'b64-toggle'], + classNameBindings: ['isInput:is-input:is-textarea'], + + /* + * Whether or not the toggle is associated with an input. + * Also bound to `is-input` and `is-textarea` classes + * Defaults to true + * + * @public + * @type boolean + */ + + isInput: true, + + /* + * The value that will be mutated when the encoding is toggled + * + * @public + * @type string + */ + value: null, + + /* + * The encoding of `value` when the component is initialized. + * Defaults to 'utf-8'. + * Possible values: 'utf-8' and 'base64' + * + * @public + * @type string + */ + initialEncoding: UTF8, + + /* + * A cached version of value - used to determine if the input has changed since encoding. + * + * @private + * @type string + */ + _value: '', + + /* + * The current encoding of `value`. + * Possible values: 'utf-8' and 'base64' + * + * @private + * @type string + */ + currentEncoding: '', + + /* + * The encoding when we last mutated `value`. + * Possible values: 'utf-8' and 'base64' + * + * @private + * @type string + */ + lastEncoding: '', + + /* + * Is the value known to be base64-encoded. + * + * @private + * @type boolean + */ + isBase64: computed.equal('currentEncoding', B64), + + /* + * Does the current value match the cached _value, i.e. has the input been mutated since we encoded. + * + * @private + * @type boolean + */ + valuesMatch: computed('value', '_value', function() { + const { value, _value } = this.getProperties('value', '_value'); + const anyBlank = Ember.isBlank(value) || Ember.isBlank(_value); + return !anyBlank && value === _value; + }), + + init() { + this._super(...arguments); + const initial = get(this, 'initialEncoding'); + set(this, 'currentEncoding', initial); + if (initial === B64) { + set(this, '_value', get(this, 'value')); + set(this, 'lastEncoding', B64); + } + }, + + didReceiveAttrs() { + // if there's no value, reset encoding + if (get(this, 'value') === '') { + set(this, 'currentEncoding', UTF8); + return; + } + // the value has changed after we transformed it so we reset currentEncoding + if (get(this, 'isBase64') && !get(this, 'valuesMatch')) { + set(this, 'currentEncoding', UTF8); + } + // the value changed back to one we previously had transformed + if (get(this, 'lastEncoding') === B64 && get(this, 'valuesMatch')) { + set(this, 'currentEncoding', B64); + } + }, + + click() { + let val = get(this, 'value'); + const isUTF8 = get(this, 'currentEncoding') === UTF8; + if (!val) { + return; + } + let newVal = isUTF8 ? encodeString(val) : decodeString(val); + const encoding = isUTF8 ? B64 : UTF8; + set(this, 'value', newVal); + set(this, '_value', newVal); + set(this, 'lastEncoding', encoding); + set(this, 'currentEncoding', encoding); + }, +}); diff --git a/ui/app/components/config-pki-ca.js b/ui/app/components/config-pki-ca.js new file mode 100644 index 000000000..6eed7d2da --- /dev/null +++ b/ui/app/components/config-pki-ca.js @@ -0,0 +1,203 @@ +import Ember from 'ember'; + +const { computed, inject } = Ember; + +export default Ember.Component.extend({ + classNames: 'config-pki-ca', + store: inject.service('store'), + flashMessages: inject.service(), + + /* + * @param boolean + * @private + * bool that gets flipped if you have a CA cert and click the Replace Cert button + */ + replaceCA: false, + + /* + * @param boolean + * @private + * bool that gets flipped if you push the click the "Set signed intermediate" button + */ + setSignedIntermediate: false, + + /* + * @param boolean + * @private + * bool that gets flipped if you push the click the "Set signed intermediate" button + */ + signIntermediate: false, + + /* + * @param boolean + * @private + * + * true when there's no CA cert currently configured + */ + needsConfig: computed.not('config.pem'), + + /* + * @param DS.Model + * @private + * + * a `pki-ca-certificate` model used to back the form when uploading or creating a CA cert + * created and set on `init`, and unloaded on willDestroy + * + */ + model: null, + + /* + * @param DS.Model + * @public + * + * a `pki-config` model - passed in in the component useage + * + */ + config: null, + + /* + * @param Function + * @public + * + * function that gets called to refresh the config model + * + */ + onRefresh: () => {}, + + loading: false, + + willDestroy() { + const ca = this.get('model'); + if (ca) { + ca.unloadRecord(); + } + this._super(...arguments); + }, + + createOrReplaceModel(modelType) { + const ca = this.get('model'); + const config = this.get('config'); + const store = this.get('store'); + const backend = config.get('backend'); + if (ca) { + ca.unloadRecord(); + } + const caCert = store.createRecord(modelType || 'pki-ca-certificate', { + id: `${backend}-ca-cert`, + backend, + }); + this.set('model', caCert); + }, + + /* + * @private + * @returns array + * + * When a CA is configured, we let them download + * the CA in der, pem, and the CA Chain in pem (if one exists) + * + * This array provides the text and download hrefs for those links. + * + */ + downloadHrefs: computed('config', 'config.{backend,pem,caChain,der}', function() { + const config = this.get('config'); + const { backend, pem, caChain, der } = config.getProperties('backend', 'pem', 'caChain', 'der'); + + if (!pem) { + return []; + } + const pemFile = new File([pem], { type: 'text/plain' }); + const links = [ + { + display: 'Download CA Certificate in PEM format', + name: `${backend}_ca.pem`, + url: URL.createObjectURL(pemFile), + }, + { + display: 'Download CA Certificate in DER format', + name: `${backend}_ca.der`, + url: URL.createObjectURL(der), + }, + ]; + if (caChain) { + const caChainFile = new File([caChain], { type: 'text/plain' }); + links.push({ + display: 'Download CA Certificate Chain', + name: `${backend}_ca_chain.pem`, + url: URL.createObjectURL(caChainFile), + }); + } + return links; + }), + + actions: { + saveCA(method) { + this.set('loading', true); + const model = this.get('model'); + const isUpload = this.get('model.uploadPemBundle'); + model + .save({ adapterOptions: { method } }) + .then(m => { + if (method === 'setSignedIntermediate' || isUpload) { + this.send('refresh'); + this.get('flashMessages').success('The certificate for this backend has been updated.'); + } else if (!m.get('certificate') && !m.get('csr')) { + // if there's no certificate, it wasn't generated and the generation was a noop + this.get('flashMessages').warning( + 'You tried to generate a new root CA, but one currently exists. To replace the existing one, delete it first and then generate again.' + ); + } + }) + .finally(() => { + this.set('loading', false); + }); + }, + deleteCA() { + this.set('loading', true); + const model = this.get('model'); + const backend = model.get('backend'); + //TODO Is there better way to do this? This forces the saved state so Ember Data will make a server call. + model.send('pushedData'); + model + .destroyRecord() + .then(() => { + this.get('flashMessages').success( + `The CA key for ${backend} has been deleted. The old CA certificate will still be accessible for reading until a new certificate/key are generated or uploaded.` + ); + }) + .finally(() => { + this.set('loading', false); + this.send('refresh'); + this.createOrReplaceModel(); + }); + }, + refresh() { + this.setProperties({ + setSignedIntermediate: false, + signIntermediate: false, + replaceCA: false, + }); + this.get('onRefresh')(); + }, + toggleReplaceCA() { + if (!this.get('replaceCA')) { + this.createOrReplaceModel(); + } + this.toggleProperty('replaceCA'); + }, + toggleVal(name, val) { + if (!name) { + return; + } + const model = name === 'signIntermediate' ? 'pki-ca-certificate-sign' : null; + if (!this.get(name)) { + this.createOrReplaceModel(model); + } + if (val !== undefined) { + this.set(name, val); + } else { + this.toggleProperty(name); + } + }, + }, +}); diff --git a/ui/app/components/config-pki.js b/ui/app/components/config-pki.js new file mode 100644 index 000000000..dd3a2165b --- /dev/null +++ b/ui/app/components/config-pki.js @@ -0,0 +1,66 @@ +import Ember from 'ember'; + +const { get, inject } = Ember; + +export default Ember.Component.extend({ + classNames: 'config-pki', + flashMessages: inject.service(), + + /* + * + * @param String + * @public + * String corresponding to the route parameter for the current section + * + */ + + section: null, + + /* + * @param DS.Model + * @public + * + * a `pki-config` model - passed in in the component useage + * + */ + config: null, + + /* + * @param Function + * @public + * + * function that gets called to refresh the config model + * + */ + onRefresh: () => {}, + + loading: false, + + actions: { + save(section) { + this.set('loading', true); + const config = this.get('config'); + config + .save({ + adapterOptions: { + method: section, + fields: get(config, `${section}Attrs`).map(attr => attr.name), + }, + }) + .then(() => { + this.get('flashMessages').success(`The ${section} config for this backend has been updated.`); + // attrs aren't persistent for Tidy + if (section === 'tidy') { + config.rollbackAttributes(); + } + this.send('refresh'); + }) + .finally(() => { + this.set('loading', false); + }); + }, + refresh() { + this.get('onRefresh')(); + }, + }, +}); diff --git a/ui/app/components/confirm-action.js b/ui/app/components/confirm-action.js new file mode 100644 index 000000000..ca6fdf519 --- /dev/null +++ b/ui/app/components/confirm-action.js @@ -0,0 +1,57 @@ +import Ember from 'ember'; +import hbs from 'htmlbars-inline-precompile'; + +export default Ember.Component.extend({ + tagName: 'span', + classNames: ['confirm-action'], + layout: hbs` + {{#if showConfirm ~}} + + {{if disabled disabledMessage confirmMessage}} + + + + {{else}} + + {{~/if}} + `, + + disabled: false, + disabledMessage: 'Complete the form to complete this action', + showConfirm: false, + messageClasses: 'is-size-8 has-text-grey', + confirmButtonClasses: 'is-danger is-outlined button', + containerClasses: '', + buttonClasses: 'button', + buttonText: 'Delete', + confirmMessage: 'Are you sure you want to do this?', + confirmButtonText: 'Delete', + cancelButtonClasses: 'button', + cancelButtonText: 'Cancel', + // the action to take when we confirm + onConfirmAction: null, + + actions: { + toggleConfirm() { + this.toggleProperty('showConfirm'); + }, + + onConfirm() { + const confirmAction = this.get('onConfirmAction'); + + if (typeof confirmAction !== 'function') { + throw new Error('confirm-action components expects `onConfirmAction` attr to be a function'); + } else { + confirmAction(); + this.toggleProperty('showConfirm'); + } + }, + }, +}); diff --git a/ui/app/components/doc-link.js b/ui/app/components/doc-link.js new file mode 100644 index 000000000..892cb2ffe --- /dev/null +++ b/ui/app/components/doc-link.js @@ -0,0 +1,19 @@ +import Ember from 'ember'; +import hbs from 'htmlbars-inline-precompile'; + +const { Component, computed } = Ember; + +export default Component.extend({ + tagName: 'a', + attributeBindings: ['target', 'rel', 'href'], + + layout: hbs`{{yield}}`, + + target: '_blank', + rel: 'noreferrer noopener', + + path: '/', + href: computed('path', function() { + return `https://www.vaultproject.io/docs${this.get('path')}`; + }), +}); diff --git a/ui/app/components/download-button.js b/ui/app/components/download-button.js new file mode 100644 index 000000000..e4761a76c --- /dev/null +++ b/ui/app/components/download-button.js @@ -0,0 +1,32 @@ +import Ember from 'ember'; +import hbs from 'htmlbars-inline-precompile'; + +const { computed } = Ember; + +export default Ember.Component.extend({ + layout: hbs`{{actionText}}`, + tagName: 'a', + role: 'button', + attributeBindings: ['role', 'download', 'href'], + download: computed('filename', 'extension', function() { + return `${this.get('filename')}-${new Date().toISOString()}.${this.get('extension')}`; + }), + + href: computed('data', 'mime', 'stringify', function() { + let data = this.get('data'); + const mime = this.get('mime'); + if (this.get('stringify')) { + data = JSON.stringify(data, null, 2); + } + + const file = new File([data], { type: mime }); + return window.URL.createObjectURL(file); + }), + + actionText: 'Download', + data: null, + filename: null, + mime: 'text/plain', + extension: 'txt', + stringify: false, +}); diff --git a/ui/app/components/edition-badge.js b/ui/app/components/edition-badge.js new file mode 100644 index 000000000..956e37257 --- /dev/null +++ b/ui/app/components/edition-badge.js @@ -0,0 +1,15 @@ +import Ember from 'ember'; + +export default Ember.Component.extend({ + tagName: 'span', + classNames: 'badge edition-badge', + abbreviation: Ember.computed('edition', function() { + const edition = this.get('edition'); + if (edition == 'Enterprise') { + return 'Ent'; + } else { + return edition; + } + }), + attributeBindings: ['edition:aria-label'], +}); diff --git a/ui/app/components/flash-message.js b/ui/app/components/flash-message.js new file mode 100644 index 000000000..02753442d --- /dev/null +++ b/ui/app/components/flash-message.js @@ -0,0 +1,17 @@ +import Ember from 'ember'; +import FlashMessage from 'ember-cli-flash/components/flash-message'; + +const { computed, getWithDefault } = Ember; + +export default FlashMessage.extend({ + // override alertType to get Bulma specific prefix + //https://github.com/poteto/ember-cli-flash/blob/master/addon/components/flash-message.js#L35 + alertType: computed('flash.type', { + get() { + const flashType = getWithDefault(this, 'flash.type', ''); + let prefix = 'notification has-border is-'; + + return `${prefix}${flashType}`; + }, + }), +}); diff --git a/ui/app/components/flex-table-column.js b/ui/app/components/flex-table-column.js new file mode 100644 index 000000000..8c57d221e --- /dev/null +++ b/ui/app/components/flex-table-column.js @@ -0,0 +1,7 @@ +import Ember from 'ember'; + +export default Ember.Component.extend({ + classNames: 'column', + header: null, + content: null, +}); diff --git a/ui/app/components/form-field-groups.js b/ui/app/components/form-field-groups.js new file mode 100644 index 000000000..86c32730d --- /dev/null +++ b/ui/app/components/form-field-groups.js @@ -0,0 +1,31 @@ +import Ember from 'ember'; + +const { computed } = Ember; + +export default Ember.Component.extend({ + tagName: '', + + /* + * @public String + * A whitelist of groups to include in the render + */ + renderGroup: computed(function() { + return null; + }), + + /* + * @public DS.Model + * model to be passed down to form-field component + * if `fieldGroups` is present on the model then it will be iterated over and + * groups of `form-field` components will be rendered + * + */ + model: null, + + /* + * @public Function + * onChange handler that will get set on the form-field component + * + */ + onChange: () => {}, +}); diff --git a/ui/app/components/form-field.js b/ui/app/components/form-field.js new file mode 100644 index 000000000..db91f3a0a --- /dev/null +++ b/ui/app/components/form-field.js @@ -0,0 +1,110 @@ +import Ember from 'ember'; +import { capitalize } from 'vault/helpers/capitalize'; +import { humanize } from 'vault/helpers/humanize'; +import { dasherize } from 'vault/helpers/dasherize'; +const { computed } = Ember; + +export default Ember.Component.extend({ + 'data-test-field': true, + classNames: ['field'], + + /* + * @public Function + * called whenever a value on the model changes via the component + * + */ + onChange() {}, + + /* + * @public + * @param Object + * in the form of + * { + * name: "foo", + * options: { + * label: "Foo", + * defaultValue: "", + * editType: "ttl", + * helpText: "This will be in a tooltip" + * }, + * type: "boolean" + * } + * + * this is usually derived from ember model `attributes` lookup, + * and all members of `attr.options` are optional + * + */ + attr: null, + + /* + * @private + * @param string + * Computed property used in the label element next to the form element + * + */ + labelString: computed('attr.name', 'attr.options.label', function() { + const label = this.get('attr.options.label'); + const name = this.get('attr.name'); + if (label) { + return label; + } + if (name) { + return capitalize([humanize([dasherize([name])])]); + } + }), + + // both the path to mutate on the model, and the path to read the value from + /* + * @private + * @param string + * + * Computed property used to set values on the passed model + * + */ + valuePath: computed('attr.name', 'attr.options.fieldValue', function() { + return this.get('attr.options.fieldValue') || this.get('attr.name'); + }), + + /* + * + * @public + * @param DS.Model + * + * the Ember Data model that `attr` is defined on + */ + model: null, + + /* + * @private + * @param object + * + * Used by the pgp-file component when an attr is editType of 'file' + */ + file: { value: '' }, + emptyData: '{\n}', + + actions: { + setFile(_, keyFile) { + const path = this.get('valuePath'); + const { value } = keyFile; + this.get('model').set(path, value); + this.get('onChange')(path, value); + this.set('file', keyFile); + }, + + setAndBroadcast(path, value) { + this.get('model').set(path, value); + this.get('onChange')(path, value); + }, + + codemirrorUpdated(path, value, codemirror) { + codemirror.performLint(); + const hasErrors = codemirror.state.lint.marked.length > 0; + + if (!hasErrors) { + this.get('model').set(path, JSON.parse(value)); + this.get('onChange')(path, JSON.parse(value)); + } + }, + }, +}); diff --git a/ui/app/components/generate-credentials.js b/ui/app/components/generate-credentials.js new file mode 100644 index 000000000..bab049ab7 --- /dev/null +++ b/ui/app/components/generate-credentials.js @@ -0,0 +1,133 @@ +import Ember from 'ember'; + +const { get, computed } = Ember; + +const MODEL_TYPES = { + 'ssh-sign': { + model: 'ssh-sign', + }, + 'ssh-creds': { + model: 'ssh-otp-credential', + title: 'Generate SSH Credentials', + generatedAttr: 'key', + }, + 'aws-creds': { + model: 'iam-credential', + title: 'Generate IAM Credentials', + generateWithoutInput: true, + backIsListLink: true, + }, + 'aws-sts': { + model: 'iam-credential', + title: 'Generate IAM Credentials with STS', + generatedAttr: 'accessKey', + }, + 'pki-issue': { + model: 'pki-certificate', + title: 'Issue Certificate', + generatedAttr: 'certificate', + }, + 'pki-sign': { + model: 'pki-certificate-sign', + title: 'Sign Certificate', + generatedAttr: 'certificate', + }, +}; + +export default Ember.Component.extend({ + store: Ember.inject.service(), + routing: Ember.inject.service('-routing'), + // set on the component + backend: null, + action: null, + role: null, + + model: null, + loading: false, + emptyData: '{\n}', + + modelForType() { + const type = this.get('options'); + if (type) { + return type.model; + } + // if we don't have a mode for that type then redirect them back to the backend list + const router = this.get('routing.router'); + router.transitionTo.call(router, 'vault.cluster.secrets.backend.list-root', this.get('model.backend')); + }, + + options: computed('action', 'backend.type', function() { + const action = this.get('action') || 'creds'; + return MODEL_TYPES[`${this.get('backend.type')}-${action}`]; + }), + + init() { + this._super(...arguments); + this.createOrReplaceModel(); + this.maybeGenerate(); + }, + + willDestroy() { + this.get('model').unloadRecord(); + this._super(...arguments); + }, + + createOrReplaceModel() { + const modelType = this.modelForType(); + const model = this.get('model'); + const roleModel = this.get('role'); + if (!modelType) { + return; + } + if (model) { + model.unloadRecord(); + } + const attrs = { + role: roleModel, + id: `${get(roleModel, 'backend')}-${get(roleModel, 'name')}`, + }; + if (this.get('action') === 'sts') { + attrs.withSTS = true; + } + const newModel = this.get('store').createRecord(modelType, attrs); + this.set('model', newModel); + }, + + /* + * + * @function maybeGenerate + * + * This method is called on `init`. If there is no input requried (as is the case for AWS IAM creds) + * then the `create` action is triggered right away. + * + */ + maybeGenerate() { + if (this.get('backend.type') !== 'aws' || this.get('action') === 'sts') { + return; + } + // for normal IAM creds - there's no input, so just generate right away + this.send('create'); + }, + + actions: { + create() { + this.set('loading', true); + this.model.save().finally(() => { + this.set('loading', false); + }); + }, + + codemirrorUpdated(attr, val, codemirror) { + codemirror.performLint(); + const hasErrors = codemirror.state.lint.marked.length > 0; + + if (!hasErrors) { + Ember.set(this.get('model'), attr, JSON.parse(val)); + } + }, + + newModel() { + this.createOrReplaceModel(); + }, + }, +}); diff --git a/ui/app/components/home-link.js b/ui/app/components/home-link.js new file mode 100644 index 000000000..7eab8f799 --- /dev/null +++ b/ui/app/components/home-link.js @@ -0,0 +1,25 @@ +import Ember from 'ember'; +import hbs from 'htmlbars-inline-precompile'; + +const { computed } = Ember; + +export default Ember.Component.extend({ + layout: hbs` + {{#if hasBlock}} + {{yield}} + {{else}} + {{text}} + {{/if}} + + `, + + tagName: '', + + text: computed(function() { + return 'home'; + }), + + computedClasses: computed('classNames', function() { + return this.get('classNames').join(' '); + }), +}); diff --git a/ui/app/components/i-con.js b/ui/app/components/i-con.js new file mode 100644 index 000000000..aedcb1895 --- /dev/null +++ b/ui/app/components/i-con.js @@ -0,0 +1,47 @@ +import Ember from 'ember'; +import hbs from 'htmlbars-inline-precompile'; + +const { computed } = Ember; +const GLYPHS_WITH_SVG_TAG = [ + 'folder', + 'file', + 'perf-replication', + 'role', + 'information-reversed', + 'true', + 'false', +]; + +export default Ember.Component.extend({ + layout: hbs` + {{#if excludeSVG}} + {{partial partialName}} + {{else}} + + {{/if}} + `, + + tagName: 'span', + excludeIconClass: false, + classNameBindings: ['excludeIconClass::icon'], + classNames: ['has-current-color-fill'], + + attributeBindings: ['aria-label', 'aria-hidden'], + + glyph: null, + + excludeSVG: computed('glyph', function() { + return GLYPHS_WITH_SVG_TAG.includes(this.get('glyph')); + }), + + size: computed(function() { + return 12; + }), + + partialName: computed('glyph', function() { + const glyph = this.get('glyph'); + return `svg/icons/${Ember.String.camelize(glyph)}`; + }), +}); diff --git a/ui/app/components/identity/edit-form.js b/ui/app/components/identity/edit-form.js new file mode 100644 index 000000000..64d222848 --- /dev/null +++ b/ui/app/components/identity/edit-form.js @@ -0,0 +1,68 @@ +import Ember from 'ember'; +import { task } from 'ember-concurrency'; +import { humanize } from 'vault/helpers/humanize'; + +const { computed } = Ember; +export default Ember.Component.extend({ + model: null, + mode: 'create', + /* + * @param Function + * @public + * + * Optional param to call a function upon successfully mounting a backend + * + */ + onSave: () => {}, + + cancelLink: computed('mode', 'model', function() { + let { model, mode } = this.getProperties('model', 'mode'); + let key = `${mode}-${model.get('identityType')}`; + let routes = { + 'create-entity': 'vault.cluster.access.identity', + 'edit-entity': 'vault.cluster.access.identity.show', + 'merge-entity-merge': 'vault.cluster.access.identity', + 'create-entity-alias': 'vault.cluster.access.identity.aliases', + 'edit-entity-alias': 'vault.cluster.access.identity.aliases.show', + 'create-group': 'vault.cluster.access.identity', + 'edit-group': 'vault.cluster.access.identity.show', + 'create-group-alias': 'vault.cluster.access.identity.aliases', + 'edit-group-alias': 'vault.cluster.access.identity.aliases.show', + }; + + return routes[key]; + }), + + getMessage(model) { + let mode = this.get('mode'); + let typeDisplay = humanize([model.get('identityType')]); + if (mode === 'merge') { + return 'Successfully merged entities'; + } + if (model.get('id')) { + return `Successfully saved ${typeDisplay} ${model.id}.`; + } + return `Successfully saved ${typeDisplay}.`; + }, + + save: task(function*() { + let model = this.get('model'); + let message = this.getMessage(model); + + try { + yield model.save(); + } catch (err) { + // err will display via model state + return; + } + this.get('flashMessages').success(message); + yield this.get('onSave')(model); + }).drop(), + + willDestroy() { + let model = this.get('model'); + if (!model.isDestroyed || !model.isDestroying) { + model.rollbackAttributes(); + } + }, +}); diff --git a/ui/app/components/identity/lookup-input.js b/ui/app/components/identity/lookup-input.js new file mode 100644 index 000000000..9dbc9cb16 --- /dev/null +++ b/ui/app/components/identity/lookup-input.js @@ -0,0 +1,75 @@ +import Ember from 'ember'; +import { task } from 'ember-concurrency'; +import { underscore } from 'vault/helpers/underscore'; + +const { inject } = Ember; + +export default Ember.Component.extend({ + store: inject.service(), + flashMessages: inject.service(), + routing: inject.service('-routing'), + + // Public API - either 'entity' or 'group' + // this will determine which adapter is used to make the lookup call + type: 'entity', + + param: 'alias name', + paramValue: null, + aliasMountAccessor: null, + + authMethods: null, + + init() { + this._super(...arguments); + this.get('store').findAll('auth-method').then(methods => { + this.set('authMethods', methods); + this.set('aliasMountAccessor', methods.get('firstObject.accessor')); + }); + }, + + adapter() { + let type = this.get('type'); + let store = this.get('store'); + return store.adapterFor(`identity/${type}`); + }, + + data() { + let { param, paramValue, aliasMountAccessor } = this.getProperties( + 'param', + 'paramValue', + 'aliasMountAccessor' + ); + let data = {}; + + data[underscore([param])] = paramValue; + if (param === 'alias name') { + data.alias_mount_accessor = aliasMountAccessor; + } + return data; + }, + + lookup: task(function*() { + let flash = this.get('flashMessages'); + let type = this.get('type'); + let store = this.get('store'); + let { param, paramValue } = this.getProperties('param', 'paramValue'); + let response; + try { + response = yield this.adapter().lookup(store, this.data()); + } catch (err) { + flash.danger( + `We encountered an error attempting the ${type} lookup: ${err.message || err.errors.join('')}.` + ); + return; + } + if (response) { + return this.get('routing.router').transitionTo( + 'vault.cluster.access.identity.show', + response.id, + 'details' + ); + } else { + flash.danger(`We were unable to find an identity ${type} with a "${param}" of "${paramValue}".`); + } + }), +}); diff --git a/ui/app/components/info-table-row.js b/ui/app/components/info-table-row.js new file mode 100644 index 000000000..914573614 --- /dev/null +++ b/ui/app/components/info-table-row.js @@ -0,0 +1,30 @@ +import Ember from 'ember'; + +export default Ember.Component.extend({ + classNames: ['info-table-row'], + isVisible: Ember.computed.or('alwaysRender', 'value'), + + /* + * @param boolean + * indicates if the component content should be always be rendered. + * when false, the value of `value` will be used to determine if the component should render + */ + alwaysRender: false, + + /* + * @param string + * the display name for the value + * + */ + label: null, + + /* + * + * the value of the data passed in - by default the content of the component will only show if there is a value + */ + value: null, + + valueIsBoolean: Ember.computed('value', function() { + return Ember.typeOf(this.get('value')) === 'boolean'; + }), +}); diff --git a/ui/app/components/info-tooltip.js b/ui/app/components/info-tooltip.js new file mode 100644 index 000000000..f7e144d45 --- /dev/null +++ b/ui/app/components/info-tooltip.js @@ -0,0 +1,7 @@ +import Ember from 'ember'; + +export default Ember.Component.extend({ + 'data-test-component': 'info-tooltip', + tagName: 'span', + classNames: ['is-inline-block'], +}); diff --git a/ui/app/components/json-editor.js b/ui/app/components/json-editor.js new file mode 100644 index 000000000..7b1a1c669 --- /dev/null +++ b/ui/app/components/json-editor.js @@ -0,0 +1,28 @@ +import IvyCodemirrorComponent from './ivy-codemirror'; +import Ember from 'ember'; + +const { assign } = Ember; +const JSON_EDITOR_DEFAULTS = { + // IMPORTANT: `gutters` must come before `lint` since the presence of + // `gutters` is cached internally when `lint` is toggled + gutters: ['CodeMirror-lint-markers'], + tabSize: 2, + mode: 'application/json', + lineNumbers: true, + lint: { lintOnChange: false }, + theme: 'hashi', + readOnly: false, +}; + +export default IvyCodemirrorComponent.extend({ + 'data-test-component': 'json-editor', + updateCodeMirrorOptions() { + const options = assign({}, JSON_EDITOR_DEFAULTS, this.get('options')); + + if (options) { + Object.keys(options).forEach(function(option) { + this.updateCodeMirrorOption(option, options[option]); + }, this); + } + }, +}); diff --git a/ui/app/components/key-value-header.js b/ui/app/components/key-value-header.js new file mode 100644 index 000000000..998c72240 --- /dev/null +++ b/ui/app/components/key-value-header.js @@ -0,0 +1,85 @@ +import Ember from 'ember'; +import utils from 'vault/lib/key-utils'; + +export default Ember.Component.extend({ + tagName: 'nav', + classNames: 'key-value-header breadcrumb', + ariaLabel: 'breadcrumbs', + attributeBindings: ['ariaLabel:aria-label', 'aria-hidden'], + + baseKey: null, + path: null, + showCurrent: true, + linkToPaths: true, + + stripTrailingSlash(str) { + return str[str.length - 1] === '/' ? str.slice(0, -1) : str; + }, + + currentPath: Ember.computed('mode', 'path', 'showCurrent', function() { + const mode = this.get('mode'); + const path = this.get('path'); + const showCurrent = this.get('showCurrent'); + if (!mode || showCurrent === false) { + return path; + } + return `vault.cluster.secrets.backend.${mode}`; + }), + + secretPath: Ember.computed('baseKey', 'baseKey.display', 'baseKey.id', 'root', 'showCurrent', function() { + let crumbs = []; + const root = this.get('root'); + const baseKey = this.get('baseKey.display') || this.get('baseKey.id'); + const baseKeyModel = this.get('baseKey.id'); + + if (root) { + crumbs.push(root); + } + + if (!baseKey) { + return crumbs; + } + + const path = this.get('path'); + const currentPath = this.get('currentPath'); + const showCurrent = this.get('showCurrent'); + const ancestors = utils.ancestorKeysForKey(baseKey); + const parts = utils.keyPartsForKey(baseKey); + if (!ancestors) { + crumbs.push({ + label: baseKey, + text: this.stripTrailingSlash(baseKey), + path: currentPath, + model: baseKeyModel, + }); + + if (!showCurrent) { + crumbs.pop(); + } + + return crumbs; + } + + ancestors.forEach((ancestor, index) => { + crumbs.push({ + label: parts[index], + text: this.stripTrailingSlash(parts[index]), + path: path, + model: ancestor, + }); + }); + + crumbs.push({ + label: utils.keyWithoutParentKey(baseKey), + text: this.stripTrailingSlash(utils.keyWithoutParentKey(baseKey)), + path: currentPath, + model: baseKeyModel, + }); + + if (!showCurrent) { + crumbs.pop(); + } + + return crumbs; + }), +}); diff --git a/ui/app/components/key-version-select.js b/ui/app/components/key-version-select.js new file mode 100644 index 000000000..e3ac4fb5c --- /dev/null +++ b/ui/app/components/key-version-select.js @@ -0,0 +1,5 @@ +import Ember from 'ember'; + +export default Ember.Component.extend({ + tagName: '', +}); diff --git a/ui/app/components/kv-object-editor.js b/ui/app/components/kv-object-editor.js new file mode 100644 index 000000000..0603998ea --- /dev/null +++ b/ui/app/components/kv-object-editor.js @@ -0,0 +1,67 @@ +import Ember from 'ember'; +import KVObject from 'vault/lib/kv-object'; + +const { assert, Component, computed, guidFor } = Ember; + +export default Component.extend({ + 'data-test-component': 'kv-object-editor', + classNames: ['field', 'form-section'], + // public API + // Ember Object to mutate + value: null, + label: null, + helpText: null, + // onChange will be called with the changed Value + onChange() {}, + + init() { + this._super(...arguments); + const data = KVObject.create({ content: [] }).fromJSON(this.get('value')); + this.set('kvData', data); + this.addRow(); + }, + + kvData: null, + + kvDataAsJSON: computed('kvData', 'kvData.[]', function() { + return this.get('kvData').toJSON(); + }), + + kvDataIsAdvanced: computed('kvData', 'kvData.[]', function() { + return this.get('kvData').isAdvanced(); + }), + + kvHasDuplicateKeys: computed('kvData', 'kvData.@each.name', function() { + let data = this.get('kvData'); + return data.uniqBy('name').length !== data.get('length'); + }), + + addRow() { + let data = this.get('kvData'); + let newObj = { name: '', value: '' }; + if (!Ember.isNone(data.findBy('name', ''))) { + return; + } + guidFor(newObj); + data.addObject(newObj); + }, + actions: { + addRow() { + this.addRow(); + }, + + updateRow() { + let data = this.get('kvData'); + this.get('onChange')(data.toJSON()); + }, + + deleteRow(object, index) { + let data = this.get('kvData'); + let oldObj = data.objectAt(index); + + assert('object guids match', guidFor(oldObj) === guidFor(object)); + data.removeAt(index); + this.get('onChange')(data.toJSON()); + }, + }, +}); diff --git a/ui/app/components/link-to.js b/ui/app/components/link-to.js new file mode 100644 index 000000000..872a2ffac --- /dev/null +++ b/ui/app/components/link-to.js @@ -0,0 +1,7 @@ +import Ember from 'ember'; + +Ember.LinkComponent.reopen({ + activeClass: 'is-active', +}); + +export default Ember.LinkComponent; diff --git a/ui/app/components/linked-block.js b/ui/app/components/linked-block.js new file mode 100644 index 000000000..cdbaa6c09 --- /dev/null +++ b/ui/app/components/linked-block.js @@ -0,0 +1,35 @@ +import Ember from 'ember'; +import hbs from 'htmlbars-inline-precompile'; + +let LinkedBlockComponent = Ember.Component.extend({ + layout: hbs`{{yield}}`, + + classNames: 'linked-block', + + routing: Ember.inject.service('-routing'), + queryParams: null, + + click(event) { + const $target = this.$(event.target); + const isAnchorOrButton = + $target.is('a') || + $target.is('button') || + $target.closest('button', event.currentTarget).length > 0 || + $target.closest('a', event.currentTarget).length > 0; + if (!isAnchorOrButton) { + const router = this.get('routing.router'); + const params = this.get('params'); + const queryParams = this.get('queryParams'); + if (queryParams) { + params.push({ queryParams }); + } + router.transitionTo.apply(router, params); + } + }, +}); + +LinkedBlockComponent.reopenClass({ + positionalParams: 'params', +}); + +export default LinkedBlockComponent; diff --git a/ui/app/components/list-pagination.js b/ui/app/components/list-pagination.js new file mode 100644 index 000000000..163a253b9 --- /dev/null +++ b/ui/app/components/list-pagination.js @@ -0,0 +1,37 @@ +import Ember from 'ember'; +import { range } from 'ember-composable-helpers/helpers/range'; + +const { computed } = Ember; + +export default Ember.Component.extend({ + classNames: ['box', 'is-shadowless', 'list-pagination'], + page: null, + lastPage: null, + link: null, + model: null, + // number of links to show on each side of page + spread: 2, + hasNext: computed('page', 'lastPage', function() { + return this.get('page') < this.get('lastPage'); + }), + hasPrevious: computed('page', 'lastPage', function() { + return this.get('page') > 1; + }), + + segmentLinks: computed.gt('lastPage', 10), + + pageRange: computed('page', 'lastPage', function() { + const { spread, page, lastPage } = this.getProperties('spread', 'page', 'lastPage'); + + let lower = Math.max(2, page - spread); + let upper = Math.min(lastPage - 1, lower + spread * 2); + // we're closer to lastPage than the spread + if (upper - lower < 5) { + lower = upper - 4; + } + if (lastPage <= 10) { + return range([1, lastPage, true]); + } + return range([lower, upper, true]); + }), +}); diff --git a/ui/app/components/logo-splash.js b/ui/app/components/logo-splash.js new file mode 100644 index 000000000..48e3f15c1 --- /dev/null +++ b/ui/app/components/logo-splash.js @@ -0,0 +1,8 @@ +import Ember from 'ember'; + +const { inject } = Ember; + +export default Ember.Component.extend({ + tagName: '', + version: inject.service(), +}); diff --git a/ui/app/components/menu-sidebar.js b/ui/app/components/menu-sidebar.js new file mode 100644 index 000000000..0382ad413 --- /dev/null +++ b/ui/app/components/menu-sidebar.js @@ -0,0 +1,15 @@ +import Ember from 'ember'; + +export default Ember.Component.extend({ + classNames: ['column', 'is-sidebar'], + classNameBindings: ['isActive:is-active'], + isActive: false, + actions: { + openMenu() { + this.set('isActive', true); + }, + closeMenu() { + this.set('isActive', false); + }, + }, +}); diff --git a/ui/app/components/message-error.js b/ui/app/components/message-error.js new file mode 100644 index 000000000..04e9f2294 --- /dev/null +++ b/ui/app/components/message-error.js @@ -0,0 +1,32 @@ +import Ember from 'ember'; + +export default Ember.Component.extend({ + tagName: '', + model: null, + errors: [], + errorMessage: null, + + displayErrors: Ember.computed( + 'errorMessage', + 'model.isError', + 'model.adapterError.errors.@each', + 'errors', + 'errors.@each', + function() { + const errorMessage = this.get('errorMessage'); + const errors = this.get('errors'); + const modelIsError = this.get('model.isError'); + if (errorMessage) { + return [errorMessage]; + } + + if (errors && errors.length > 0) { + return errors; + } + + if (modelIsError) { + return this.get('model.adapterError.errors'); + } + } + ), +}); diff --git a/ui/app/components/message-in-page.js b/ui/app/components/message-in-page.js new file mode 100644 index 000000000..f25076d90 --- /dev/null +++ b/ui/app/components/message-in-page.js @@ -0,0 +1,20 @@ +import Ember from 'ember'; + +import { messageTypes } from 'vault/helpers/message-types'; +const { computed } = Ember; + +export default Ember.Component.extend({ + type: null, + + classNameBindings: ['containerClass'], + + containerClass: computed('type', function() { + return 'message ' + messageTypes([this.get('type')]).class; + }), + + alertType: computed('type', function() { + return messageTypes([this.get('type')]); + }), + + messageClass: 'message-body', +}); diff --git a/ui/app/components/mount-accessor-select.js b/ui/app/components/mount-accessor-select.js new file mode 100644 index 000000000..bdd5d7c97 --- /dev/null +++ b/ui/app/components/mount-accessor-select.js @@ -0,0 +1,32 @@ +import Ember from 'ember'; +import { task } from 'ember-concurrency'; + +const { inject } = Ember; + +export default Ember.Component.extend({ + store: inject.service(), + + // Public API + //value for the external mount selector + value: null, + onChange: () => {}, + + init() { + this._super(...arguments); + this.get('authMethods').perform(); + }, + + authMethods: task(function*() { + let methods = yield this.get('store').findAll('auth-method'); + if (!this.get('value')) { + this.set('value', methods.get('firstObject.accessor')); + } + return methods; + }).drop(), + + actions: { + change(value) { + this.get('onChange')(value); + }, + }, +}); diff --git a/ui/app/components/mount-backend-form.js b/ui/app/components/mount-backend-form.js new file mode 100644 index 000000000..d9c48dfd6 --- /dev/null +++ b/ui/app/components/mount-backend-form.js @@ -0,0 +1,137 @@ +import Ember from 'ember'; +import { task } from 'ember-concurrency'; +import { methods } from 'vault/helpers/mountable-auth-methods'; + +const { inject } = Ember; +const METHODS = methods(); + +export default Ember.Component.extend({ + store: inject.service(), + flashMessages: inject.service(), + routing: inject.service('-routing'), + + /* + * @param Function + * @public + * + * Optional param to call a function upon successfully mounting a backend + * + */ + onMountSuccess: () => {}, + onConfigError: () => {}, + /* + * @param String + * @public + * the type of backend we want to mount + * defaults to `auth` + * + */ + mountType: 'auth', + + /* + * + * @param DS.Model + * @private + * Ember Data model corresponding to the `mountType`. + * Created and set during `init` + * + */ + mountModel: null, + + init() { + this._super(...arguments); + const type = this.get('mountType'); + const modelType = type === 'secret' ? 'secret-engine' : 'auth-method'; + const model = this.get('store').createRecord(modelType); + this.set('mountModel', model); + this.changeConfigModel(model.get('type')); + }, + + willDestroy() { + // if unsaved, we want to unload so it doesn't show up in the auth mount list + this.get('mountModel').rollbackAttributes(); + }, + + getConfigModelType(methodType) { + let noConfig = ['approle']; + if (noConfig.includes(methodType)) { + return; + } + if (methodType === 'aws') { + return 'auth-config/aws/client'; + } + return `auth-config/${methodType}`; + }, + + changeConfigModel(methodType) { + const mount = this.get('mountModel'); + const configRef = mount.hasMany('authConfigs').value(); + const currentConfig = configRef.get('firstObject'); + if (currentConfig) { + // rollbackAttributes here will remove the the config model from the store + // because `isNew` will be true + currentConfig.rollbackAttributes(); + } + const configType = this.getConfigModelType(methodType); + if (!configType) return; + const config = this.get('store').createRecord(configType); + config.set('backend', mount); + }, + + checkPathChange(type) { + const mount = this.get('mountModel'); + const currentPath = mount.get('path'); + // if the current path matches a type (meaning the user hasn't altered it), + // change it here to match the new type + const isUnchanged = METHODS.findBy('type', currentPath); + if (isUnchanged) { + mount.set('path', type); + } + }, + + mountBackend: task(function*() { + const mountModel = this.get('mountModel'); + const { type, path } = mountModel.getProperties('type', 'path'); + try { + yield mountModel.save(); + } catch (err) { + // err will display via model state + return; + } + this.get('flashMessages').success( + `Successfully mounted ${type} ${this.get('mountType')} method at ${path}.` + ); + yield this.get('saveConfig').perform(mountModel); + }).drop(), + + saveConfig: task(function*(mountModel) { + const configRef = mountModel.hasMany('authConfigs').value(); + const config = configRef.get('firstObject'); + const { type, path } = mountModel.getProperties('type', 'path'); + try { + if (config && Object.keys(config.changedAttributes()).length) { + yield config.save(); + this.get('flashMessages').success( + `The config for ${type} ${this.get('mountType')} method at ${path} was saved successfully.` + ); + } + yield this.get('onMountSuccess')(); + } catch (err) { + this.get('flashMessages').danger( + `There was an error saving the configuration for ${type} ${this.get( + 'mountType' + )} method at ${path}. ${err.errors.join(' ')}` + ); + yield this.get('onConfigError')(mountModel.id); + } + }).drop(), + + actions: { + onTypeChange(path, value) { + if (path === 'type') { + this.changeConfigModel(value); + this.checkPathChange(value); + } + }, + }, +}); diff --git a/ui/app/components/mount-filter-config-list.js b/ui/app/components/mount-filter-config-list.js new file mode 100644 index 000000000..848d62c93 --- /dev/null +++ b/ui/app/components/mount-filter-config-list.js @@ -0,0 +1,26 @@ +import Ember from 'ember'; + +const { get, set } = Ember; + +export default Ember.Component.extend({ + config: null, + mounts: null, + + // singleton mounts are not eligible for per-mount-filtering + singletonMountTypes: ['cubbyhole', 'system', 'token', 'identity'], + + actions: { + addOrRemovePath(path, e) { + let config = get(this, 'config') || []; + let paths = get(config, 'paths').slice(); + + if (e.target.checked) { + paths.addObject(path); + } else { + paths.removeObject(path); + } + + set(config, 'paths', paths); + }, + }, +}); diff --git a/ui/app/components/navigate-input.js b/ui/app/components/navigate-input.js new file mode 100644 index 000000000..f56c22750 --- /dev/null +++ b/ui/app/components/navigate-input.js @@ -0,0 +1,190 @@ +import Ember from 'ember'; +import utils from 'vault/lib/key-utils'; +import keys from 'vault/lib/keycodes'; +import FocusOnInsertMixin from 'vault/mixins/focus-on-insert'; + +const routeFor = function(type, mode) { + const MODES = { + secrets: 'vault.cluster.secrets.backend', + 'secrets-cert': 'vault.cluster.secrets.backend', + 'policy-show': 'vault.cluster.policy', + 'policy-list': 'vault.cluster.policies', + leases: 'vault.cluster.access.leases', + }; + let useSuffix = true; + const typeVal = mode === 'secrets' || mode === 'leases' ? type : type.replace('-root', ''); + const modeKey = mode + '-' + typeVal; + const modeVal = MODES[modeKey] || MODES[mode]; + if (modeKey === 'policy-list') { + useSuffix = false; + } + + return useSuffix ? modeVal + '.' + typeVal : modeVal; +}; + +export default Ember.Component.extend(FocusOnInsertMixin, { + classNames: ['navigate-filter'], + + // these get passed in from the outside + // actions that get passed in + filterFocusDidChange: null, + filterDidChange: null, + mode: 'secrets', + shouldNavigateTree: false, + extraNavParams: null, + + baseKey: null, + filter: null, + filterMatchesKey: null, + firstPartialMatch: null, + + routing: Ember.inject.service('-routing'), + + transitionToRoute: function() { + var router = this.get('routing.router'); + router.transitionTo.apply(router, arguments); + }, + + shouldFocus: false, + + focusFilter: Ember.observer('filter', function() { + if (!this.get('filter')) return; + Ember.run.schedule('afterRender', this, 'forceFocus'); + }).on('didInsertElement'), + + keyForNav(key) { + if (this.get('mode') !== 'secrets-cert') { + return key; + } + return `cert/${key}`; + }, + onEnter: function(val) { + let baseKey = this.get('baseKey'); + let mode = this.get('mode'); + let extraParams = this.get('extraNavParams'); + if (mode.startsWith('secrets') && (!val || val === baseKey)) { + return; + } + if (this.get('filterMatchesKey') && !utils.keyIsFolder(val)) { + let params = [routeFor('show', mode), extraParams, this.keyForNav(val)].compact(); + this.transitionToRoute(...params); + } else { + if (mode === 'policies') { + return; + } + let route = routeFor('create', mode); + if (baseKey) { + this.transitionToRoute(route, this.keyForNav(baseKey), { + queryParams: { + initialKey: val.replace(this.keyForNav(baseKey), ''), + }, + }); + } else { + this.transitionToRoute(route + '-root', { + queryParams: { + initialKey: this.keyForNav(val), + }, + }); + } + } + }, + + // pop to the nearest parentKey or to the root + onEscape: function(val) { + var key = utils.parentKeyForKey(val) || ''; + this.get('filterDidChange')(key); + this.filterUpdated(key); + }, + + onTab: function(event) { + var firstPartialMatch = this.get('firstPartialMatch.id'); + if (!firstPartialMatch) { + return; + } + event.preventDefault(); + this.get('filterDidChange')(firstPartialMatch); + this.filterUpdated(firstPartialMatch); + }, + + // as you type, navigates through the k/v tree + filterUpdated: function(val) { + var mode = this.get('mode'); + if (mode === 'policies' || !this.get('shouldNavigateTree')) { + this.filterUpdatedNoNav(val, mode); + return; + } + // select the key to nav to, assumed to be a folder + var key = val ? val.trim() : ''; + var isFolder = utils.keyIsFolder(key); + + if (!isFolder) { + // nav to the closest parentKey (or the root) + key = utils.parentKeyForKey(val) || ''; + } + + const pageFilter = val.replace(key, ''); + this.navigate(this.keyForNav(key), mode, pageFilter); + }, + + navigate(key, mode, pageFilter) { + const route = routeFor(key ? 'list' : 'list-root', mode); + let args = [route]; + if (key) { + args.push(key); + } + if (pageFilter && !utils.keyIsFolder(pageFilter)) { + args.push({ + queryParams: { + page: 1, + pageFilter, + }, + }); + } else { + args.push({ + queryParams: { + page: 1, + pageFilter: null, + }, + }); + } + this.transitionToRoute(...args); + }, + + filterUpdatedNoNav: function(val, mode) { + var key = val ? val.trim() : null; + this.transitionToRoute(routeFor('list-root', mode), { + queryParams: { + pageFilter: key, + page: 1, + }, + }); + }, + + actions: { + handleInput: function(event) { + var filter = event.target.value; + this.get('filterDidChange')(filter); + Ember.run.debounce(this, 'filterUpdated', filter, 200); + }, + + setFilterFocused: function(isFocused) { + this.get('filterFocusDidChange')(isFocused); + }, + + handleKeyPress: function(val, event) { + if (event.keyCode === keys.TAB) { + this.onTab(event); + } + }, + + handleKeyUp: function(val, event) { + var keyCode = event.keyCode; + if (keyCode === keys.ENTER) { + this.onEnter(val); + } + if (keyCode === keys.ESC) { + this.onEscape(val); + } + }, + }, +}); diff --git a/ui/app/components/not-found.js b/ui/app/components/not-found.js new file mode 100644 index 000000000..5d76dd583 --- /dev/null +++ b/ui/app/components/not-found.js @@ -0,0 +1,12 @@ +import Ember from 'ember'; + +const { computed, inject } = Ember; + +export default Ember.Component.extend({ + // public + model: null, + + tagName: '', + routing: inject.service('-routing'), + path: computed.alias('routing.router.currentURL'), +}); diff --git a/ui/app/components/pgp-file.js b/ui/app/components/pgp-file.js new file mode 100644 index 000000000..43735afce --- /dev/null +++ b/ui/app/components/pgp-file.js @@ -0,0 +1,74 @@ +import Ember from 'ember'; + +const { set } = Ember; +const BASE_64_REGEX = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/gi; + +export default Ember.Component.extend({ + classNames: ['box', 'is-fullwidth', 'is-marginless', 'is-shadowless'], + key: null, + index: null, + onChange: () => {}, + + /* + * @public + * @param String + * Text to use as the label for the file input + * If null, a default will be rendered + */ + label: null, + + /* + * @public + * @param String + * Text to use as help under the file input + * If null, a default will be rendered + */ + fileHelpText: null, + + /* + * @public + * @param String + * Text to use as help under the textarea in text-input mode + * If null, a default will be rendered + */ + textareaHelpText: null, + + readFile(file) { + const reader = new FileReader(); + reader.onload = () => this.setPGPKey(reader.result, file.name); + // this gives us a base64-encoded string which is important in the onload + reader.readAsDataURL(file); + }, + + setPGPKey(dataURL, filename) { + const b64File = dataURL.split(',')[1].trim(); + const decoded = atob(b64File).trim(); + + // If a b64-encoded file was uploaded, then after decoding, it + // will still be b64. + // If after decoding it's not b64, we want + // the original as it was only encoded when we used `readAsDataURL`. + const fileData = decoded.match(BASE_64_REGEX) ? decoded : b64File; + this.get('onChange')(this.get('index'), { value: fileData, fileName: filename }); + }, + + actions: { + pickedFile(e) { + const { files } = e.target; + if (!files.length) { + return; + } + for (let i = 0, len = files.length; i < len; i++) { + this.readFile(files[i]); + } + }, + updateData(e) { + const key = this.get('key'); + set(key, 'value', e.target.value); + this.get('onChange')(this.get('index'), this.get('key')); + }, + clearKey() { + this.get('onChange')(this.get('index'), { value: '' }); + }, + }, +}); diff --git a/ui/app/components/pgp-list.js b/ui/app/components/pgp-list.js new file mode 100644 index 000000000..b8aed1c78 --- /dev/null +++ b/ui/app/components/pgp-list.js @@ -0,0 +1,20 @@ +import Ember from 'ember'; + +export default Ember.Component.extend({ + onDataUpdate: () => {}, + listData: Ember.computed('listLength', function() { + let num = this.get('listLength'); + if (num) { + num = parseInt(num, 10); + } + return Array(num || 0).fill(null).map(() => ({ value: '' })); + }), + listLength: 0, + actions: { + setKey(index, key) { + let listData = this.get('listData'); + listData.replace(index, 1, key); + this.get('onDataUpdate')(listData.compact().map(k => k.value)); + }, + }, +}); diff --git a/ui/app/components/pki-cert-popup.js b/ui/app/components/pki-cert-popup.js new file mode 100644 index 000000000..64faaeb59 --- /dev/null +++ b/ui/app/components/pki-cert-popup.js @@ -0,0 +1,17 @@ +import Ember from 'ember'; + +export default Ember.Component.extend({ + /* + * @public + * @param DS.Model + * + * the pki-certificate model + */ + item: null, + + actions: { + delete(item) { + item.save({ adapterOptions: { method: 'revoke' } }); + }, + }, +}); diff --git a/ui/app/components/pki-cert-show.js b/ui/app/components/pki-cert-show.js new file mode 100644 index 000000000..4a2e09c45 --- /dev/null +++ b/ui/app/components/pki-cert-show.js @@ -0,0 +1,9 @@ +import RoleEdit from './role-edit'; + +export default RoleEdit.extend({ + actions: { + delete() { + this.get('model').save({ adapterOptions: { method: 'revoke' } }); + }, + }, +}); diff --git a/ui/app/components/popup-menu.js b/ui/app/components/popup-menu.js new file mode 100644 index 000000000..e3ac4fb5c --- /dev/null +++ b/ui/app/components/popup-menu.js @@ -0,0 +1,5 @@ +import Ember from 'ember'; + +export default Ember.Component.extend({ + tagName: '', +}); diff --git a/ui/app/components/replication-actions.js b/ui/app/components/replication-actions.js new file mode 100644 index 000000000..2e1b22a90 --- /dev/null +++ b/ui/app/components/replication-actions.js @@ -0,0 +1,58 @@ +import Ember from 'ember'; +import ReplicationActions from 'vault/mixins/replication-actions'; + +const { computed } = Ember; + +const DEFAULTS = { + token: null, + primary_api_addr: null, + primary_cluster_addr: null, + errors: [], + id: null, + replicationMode: null, +}; + +export default Ember.Component.extend(ReplicationActions, DEFAULTS, { + replicationMode: null, + selectedAction: null, + tagName: 'form', + + didReceiveAttrs() { + this._super(...arguments); + }, + + model: null, + cluster: computed.alias('model'), + loading: false, + onSubmit: null, + + reset() { + if (!this || this.isDestroyed || this.isDestroying) { + return; + } + this.setProperties(DEFAULTS); + }, + + replicationDisplayMode: computed('replicationMode', function() { + const replicationMode = this.get('replicationMode'); + if (replicationMode === 'dr') { + return 'DR'; + } + if (replicationMode === 'performance') { + return 'Performance'; + } + }), + + actions: { + onSubmit() { + return this.submitHandler(...arguments); + }, + clear() { + this.reset(); + this.setProperties({ + token: null, + id: null, + }); + }, + }, +}); diff --git a/ui/app/components/replication-mode-summary.js b/ui/app/components/replication-mode-summary.js new file mode 100644 index 000000000..6c26d98ec --- /dev/null +++ b/ui/app/components/replication-mode-summary.js @@ -0,0 +1,51 @@ +import Ember from 'ember'; +import { hrefTo } from 'vault/helpers/href-to'; +const { computed, get, getProperties } = Ember; + +const replicationAttr = function(attr) { + return computed('mode', `cluster.{dr,performance}.${attr}`, function() { + const { mode, cluster } = getProperties(this, 'mode', 'cluster'); + return get(cluster, `${mode}.${attr}`); + }); +}; +export default Ember.Component.extend({ + version: Ember.inject.service(), + classNames: ['level', 'box-label'], + classNameBindings: ['isMenu:is-mobile'], + attributeBindings: ['href', 'target'], + display: 'banner', + isMenu: computed.equal('display', 'menu'), + href: computed('display', 'mode', 'replicationEnabled', 'version.hasPerfReplication', function() { + const display = this.get('display'); + const mode = this.get('mode'); + if (mode === 'performance' && display === 'menu' && this.get('version.hasPerfReplication') === false) { + return 'https://www.hashicorp.com/products/vault'; + } + if (this.get('replicationEnabled') || display === 'menu') { + return hrefTo(this, 'vault.cluster.replication.mode.index', this.get('cluster.name'), mode); + } + return null; + }), + target: computed('isPerformance', 'version.hasPerfReplication', function() { + if (this.get('isPerformance') && this.get('version.hasPerfReplication') === false) { + return '_blank'; + } + return null; + }), + isPerformance: computed.equal('mode', 'performance'), + replicationEnabled: replicationAttr('replicationEnabled'), + replicationUnsupported: replicationAttr('replicationUnsupported'), + replicationDisabled: replicationAttr('replicationDisabled'), + syncProgressPercent: replicationAttr('syncProgressPercent'), + syncProgress: replicationAttr('syncProgress'), + secondaryId: replicationAttr('secondaryId'), + modeForUrl: replicationAttr('modeForUrl'), + clusterIdDisplay: replicationAttr('clusterIdDisplay'), + mode: null, + cluster: null, + partialName: computed('display', function() { + return this.get('display') === 'menu' + ? 'partials/replication/replication-mode-summary-menu' + : 'partials/replication/replication-mode-summary'; + }), +}); diff --git a/ui/app/components/replication-secondaries.js b/ui/app/components/replication-secondaries.js new file mode 100644 index 000000000..c13704b15 --- /dev/null +++ b/ui/app/components/replication-secondaries.js @@ -0,0 +1,19 @@ +import Ember from 'ember'; + +const { computed } = Ember; + +export default Ember.Component.extend({ + cluster: null, + replicationMode: null, + secondaries: null, + onRevoke: Function.prototype, + + addRoute: computed('replicationMode', function() {}), + revokeRoute: computed('replicationMode', function() {}), + + actions: { + onConfirmRevoke() { + this.get('onRevoke')(...arguments); + }, + }, +}); diff --git a/ui/app/components/replication-summary.js b/ui/app/components/replication-summary.js new file mode 100644 index 000000000..35ae26fe1 --- /dev/null +++ b/ui/app/components/replication-summary.js @@ -0,0 +1,80 @@ +import Ember from 'ember'; +import decodeConfigFromJWT from 'vault/utils/decode-config-from-jwt'; +import ReplicationActions from 'vault/mixins/replication-actions'; + +const { computed, get } = Ember; + +const DEFAULTS = { + mode: 'primary', + token: null, + id: null, + loading: false, + errors: [], + primary_api_addr: null, + primary_cluster_addr: null, + ca_file: null, + ca_path: null, + replicationMode: 'dr', +}; + +export default Ember.Component.extend(ReplicationActions, DEFAULTS, { + didReceiveAttrs() { + this._super(...arguments); + const initialReplicationMode = this.get('initialReplicationMode'); + if (initialReplicationMode) { + this.set('replicationMode', initialReplicationMode); + } + }, + showModeSummary: false, + initialReplicationMode: null, + cluster: null, + version: Ember.inject.service(), + + replicationAttrs: computed.alias('cluster.replicationAttrs'), + + tokenIncludesAPIAddr: computed('token', function() { + const config = decodeConfigFromJWT(get(this, 'token')); + return config && config.addr ? true : false; + }), + + disallowEnable: computed( + 'replicationMode', + 'version.hasPerfReplication', + 'mode', + 'tokenIncludesAPIAddr', + 'primary_api_addr', + function() { + const inculdesAPIAddr = this.get('tokenIncludesAPIAddr'); + if (this.get('replicationMode') === 'performance' && this.get('version.hasPerfReplication') === false) { + return true; + } + if ( + this.get('mode') !== 'secondary' || + inculdesAPIAddr || + (!inculdesAPIAddr && this.get('primary_api_addr')) + ) { + return false; + } + + return true; + } + ), + + reset() { + this.setProperties(DEFAULTS); + }, + + actions: { + onSubmit(/*action, mode, data, event*/) { + return this.submitHandler(...arguments); + }, + + clear() { + this.reset(); + this.setProperties({ + token: null, + id: null, + }); + }, + }, +}); diff --git a/ui/app/components/role-aws-edit.js b/ui/app/components/role-aws-edit.js new file mode 100644 index 000000000..0b5fb5ec6 --- /dev/null +++ b/ui/app/components/role-aws-edit.js @@ -0,0 +1,49 @@ +import RoleEdit from './role-edit'; +import Ember from 'ember'; + +const { get, set } = Ember; +const SHOW_ROUTE = 'vault.cluster.secrets.backend.show'; + +export default RoleEdit.extend({ + useARN: false, + init() { + this._super(...arguments); + const arn = get(this, 'model.arn'); + if (arn) { + set(this, 'useARN', true); + } + }, + + actions: { + createOrUpdate(type, event) { + event.preventDefault(); + + const modelId = this.get('model.id'); + // prevent from submitting if there's no key + // maybe do something fancier later + if (type === 'create' && Ember.isBlank(modelId)) { + return; + } + // clear the policy or arn before save depending on "useARN" + if (get(this, 'useARN')) { + set(this, 'model.policy', ''); + } else { + set(this, 'model.arn', ''); + } + + this.persist('save', () => { + this.hasDataChanges(); + this.transitionToRoute(SHOW_ROUTE, modelId); + }); + }, + + codemirrorUpdated(attr, val, codemirror) { + codemirror.performLint(); + const hasErrors = codemirror.state.lint.marked.length > 0; + + if (!hasErrors) { + set(this.get('model'), attr, val); + } + }, + }, +}); diff --git a/ui/app/components/role-edit.js b/ui/app/components/role-edit.js new file mode 100644 index 000000000..4f84c51b1 --- /dev/null +++ b/ui/app/components/role-edit.js @@ -0,0 +1,114 @@ +import Ember from 'ember'; +import FocusOnInsertMixin from 'vault/mixins/focus-on-insert'; +import keys from 'vault/lib/keycodes'; + +const { get, set, computed } = Ember; +const LIST_ROOT_ROUTE = 'vault.cluster.secrets.backend.list-root'; +const SHOW_ROUTE = 'vault.cluster.secrets.backend.show'; + +export default Ember.Component.extend(FocusOnInsertMixin, { + mode: null, + emptyData: '{\n}', + onDataChange: () => {}, + refresh: 'refresh', + model: null, + routing: Ember.inject.service('-routing'), + requestInFlight: computed.or('model.isLoading', 'model.isReloading', 'model.isSaving'), + willDestroyElement() { + const model = this.get('model'); + if (get(model, 'isError')) { + model.rollbackAttributes(); + } + }, + + transitionToRoute() { + const router = this.get('routing.router'); + router.transitionTo.apply(router, arguments); + }, + + bindKeys: Ember.on('didInsertElement', function() { + Ember.$(document).on('keyup.keyEdit', this.onEscape.bind(this)); + }), + + unbindKeys: Ember.on('willDestroyElement', function() { + Ember.$(document).off('keyup.keyEdit'); + }), + + onEscape(e) { + if (e.keyCode !== keys.ESC || this.get('mode') !== 'show') { + return; + } + this.transitionToRoute(LIST_ROOT_ROUTE); + }, + + hasDataChanges() { + get(this, 'onDataChange')(get(this, 'model.hasDirtyAttributes')); + }, + + persist(method, successCallback) { + const model = get(this, 'model'); + return model[method]().then(result => { + if (!Ember.get(result, 'didError')) { + successCallback(model); + } + }); + }, + + actions: { + handleKeyDown(_, e) { + e.stopPropagation(); + if (!(e.keyCode === keys.ENTER && e.metaKey)) { + return; + } + let $form = this.$('form'); + if ($form.length) { + $form.submit(); + } + $form = null; + }, + + createOrUpdate(type, event) { + event.preventDefault(); + + const modelId = this.get('model.id'); + // prevent from submitting if there's no key + // maybe do something fancier later + if (type === 'create' && Ember.isBlank(modelId)) { + return; + } + + this.persist('save', () => { + this.hasDataChanges(); + this.transitionToRoute(SHOW_ROUTE, modelId); + }); + }, + + handleChange() { + this.hasDataChanges(); + }, + + setValue(key, event) { + set(get(this, 'model'), key, event.target.checked); + }, + + refresh() { + this.sendAction('refresh'); + }, + + delete() { + this.persist('destroyRecord', () => { + this.hasDataChanges(); + this.transitionToRoute(LIST_ROOT_ROUTE); + }); + }, + + codemirrorUpdated(attr, val, codemirror) { + codemirror.performLint(); + const hasErrors = codemirror.state.lint.marked.length > 0; + + if (!hasErrors) { + set(this.get('model'), attr, JSON.parse(val)); + } + }, + }, +}); diff --git a/ui/app/components/role-pki-edit.js b/ui/app/components/role-pki-edit.js new file mode 100644 index 000000000..25d06d37a --- /dev/null +++ b/ui/app/components/role-pki-edit.js @@ -0,0 +1,3 @@ +import RoleEdit from './role-edit'; + +export default RoleEdit.extend({}); diff --git a/ui/app/components/role-ssh-edit.js b/ui/app/components/role-ssh-edit.js new file mode 100644 index 000000000..25d06d37a --- /dev/null +++ b/ui/app/components/role-ssh-edit.js @@ -0,0 +1,3 @@ +import RoleEdit from './role-edit'; + +export default RoleEdit.extend({}); diff --git a/ui/app/components/secret-edit.js b/ui/app/components/secret-edit.js new file mode 100644 index 000000000..ea0d8b7d5 --- /dev/null +++ b/ui/app/components/secret-edit.js @@ -0,0 +1,251 @@ +import Ember from 'ember'; +import FocusOnInsertMixin from 'vault/mixins/focus-on-insert'; +import keys from 'vault/lib/keycodes'; +import autosize from 'autosize'; +import KVObject from 'vault/lib/kv-object'; + +const LIST_ROUTE = 'vault.cluster.secrets.backend.list'; +const LIST_ROOT_ROUTE = 'vault.cluster.secrets.backend.list-root'; +const SHOW_ROUTE = 'vault.cluster.secrets.backend.show'; +const { get, computed } = Ember; + +export default Ember.Component.extend(FocusOnInsertMixin, { + // a key model + key: null, + + // a value to pre-fill the key input - this is populated by the corresponding + // 'initialKey' queryParam + initialKey: null, + + // set in the route's setupController hook + mode: null, + + secretData: null, + + // called with a bool indicating if there's been a change in the secretData + onDataChange: () => {}, + + // did user request advanced mode + preferAdvancedEdit: false, + + // use a named action here so we don't have to pass one in + // this will bubble to the route + toggleAdvancedEdit: 'toggleAdvancedEdit', + + codemirrorString: null, + + hasLintError: false, + + init() { + this._super(...arguments); + const secrets = this.get('key.secretData'); + const data = KVObject.create({ content: [] }).fromJSON(secrets); + this.set('secretData', data); + this.set('codemirrorString', data.toJSONString()); + if (data.isAdvanced()) { + this.set('preferAdvancedEdit', true); + } + this.checkRows(); + if (this.get('mode') === 'edit') { + this.send('addRow'); + } + }, + + didRender() { + const textareas = this.$('textarea'); + if (textareas.length) { + autosize(textareas); + } + }, + + willDestroyElement() { + const key = this.get('key'); + if (get(key, 'isError') && !key.isDestroyed) { + key.rollbackAttributes(); + } + }, + + partialName: Ember.computed('mode', function() { + return `partials/secret-form-${this.get('mode')}`; + }), + + routing: Ember.inject.service('-routing'), + + showPrefix: computed.or('key.initialParentKey', 'key.parentKey'), + + requestInFlight: computed.or('key.isLoading', 'key.isReloading', 'key.isSaving'), + + buttonDisabled: computed.or( + 'requestInFlight', + 'key.isFolder', + 'key.didError', + 'key.flagsIsInvalid', + 'hasLintError' + ), + + basicModeDisabled: computed('secretDataIsAdvanced', 'showAdvancedMode', function() { + return this.get('secretDataIsAdvanced') || this.get('showAdvancedMode') === false; + }), + + secretDataAsJSON: computed('secretData', 'secretData.[]', function() { + return this.get('secretData').toJSON(); + }), + + secretDataIsAdvanced: computed('secretData', 'secretData.[]', function() { + return this.get('secretData').isAdvanced(); + }), + + hasDataChanges() { + const keyDataString = this.get('key.dataAsJSONString'); + const sameData = this.get('secretData').toJSONString() === keyDataString; + if (sameData === false) { + this.set('lastChange', Date.now()); + } + + this.get('onDataChange')(!sameData); + }, + + showAdvancedMode: computed('preferAdvancedEdit', 'secretDataIsAdvanced', 'lastChange', function() { + return this.get('secretDataIsAdvanced') || this.get('preferAdvancedEdit'); + }), + + transitionToRoute() { + const router = this.get('routing.router'); + router.transitionTo.apply(router, arguments); + }, + + bindKeys: Ember.on('didInsertElement', function() { + Ember.$(document).on('keyup.keyEdit', this.onEscape.bind(this)); + }), + + unbindKeys: Ember.on('willDestroyElement', function() { + Ember.$(document).off('keyup.keyEdit'); + }), + + onEscape(e) { + if (e.keyCode !== keys.ESC || this.get('mode') !== 'show') { + return; + } + const parentKey = this.get('key.parentKey'); + if (parentKey) { + this.transitionToRoute(LIST_ROUTE, parentKey); + } else { + this.transitionToRoute(LIST_ROOT_ROUTE); + } + }, + + // successCallback is called in the context of the component + persistKey(method, successCallback, isCreate) { + let model = this.get('key'); + const key = model.get('id'); + + if (isCreate && typeof model.createRecord === 'function') { + // create an ember data model from the proxy + model = model.createRecord(model.get('backend')); + this.set('key', model); + } + + return model[method]().then(result => { + if (!Ember.get(result, 'didError')) { + successCallback(key); + } + }); + }, + + checkRows() { + if (this.get('secretData').get('length') === 0) { + this.send('addRow'); + } + }, + + actions: { + handleKeyDown(_, e) { + e.stopPropagation(); + if (!(e.keyCode === keys.ENTER && e.metaKey)) { + return; + } + let $form = this.$('form'); + if ($form.length) { + $form.submit(); + } + $form = null; + }, + + handleChange() { + this.set('codemirrorString', this.get('secretData').toJSONString(true)); + this.hasDataChanges(); + }, + + createOrUpdateKey(type, event) { + event.preventDefault(); + const newData = this.get('secretData').toJSON(); + this.get('key').set('secretData', newData); + + // prevent from submitting if there's no key + // maybe do something fancier later + if (type === 'create' && Ember.isBlank(this.get('key.id'))) { + return; + } + + this.persistKey( + 'save', + key => { + this.hasDataChanges(); + this.transitionToRoute(SHOW_ROUTE, key); + }, + type === 'create' + ); + }, + + deleteKey() { + this.persistKey('destroyRecord', () => { + this.transitionToRoute(LIST_ROOT_ROUTE); + }); + }, + + refresh() { + this.attrs.onRefresh(); + }, + + addRow() { + const data = this.get('secretData'); + if (Ember.isNone(data.findBy('name', ''))) { + data.pushObject({ name: '', value: '' }); + this.set('codemirrorString', data.toJSONString(true)); + } + this.checkRows(); + this.hasDataChanges(); + }, + + deleteRow(name) { + const data = this.get('secretData'); + const item = data.findBy('name', name); + if (Ember.isBlank(item.name)) { + return; + } + data.removeObject(item); + this.checkRows(); + this.hasDataChanges(); + this.set('codemirrorString', data.toJSONString(true)); + this.rerender(); + }, + + toggleAdvanced(bool) { + this.sendAction('toggleAdvancedEdit', bool); + }, + + codemirrorUpdated(val, codemirror) { + codemirror.performLint(); + const noErrors = codemirror.state.lint.marked.length === 0; + if (noErrors) { + this.get('secretData').fromJSONString(val); + } + this.set('hasLintError', !noErrors); + this.set('codemirrorString', val); + }, + + formatJSON() { + this.set('codemirrorString', this.get('secretData').toJSONString(true)); + }, + }, +}); diff --git a/ui/app/components/secret-form-header.js b/ui/app/components/secret-form-header.js new file mode 100644 index 000000000..1a20cb820 --- /dev/null +++ b/ui/app/components/secret-form-header.js @@ -0,0 +1,49 @@ +import Ember from 'ember'; +import hbs from 'htmlbars-inline-precompile'; + +export default Ember.Component.extend({ + key: null, + mode: null, + path: null, + actionClass: null, + + title: Ember.computed.alias('key.keyWithoutParent'), + + layout: hbs` +
+ {{#secret-link + mode="list" + secret=key.parentKey + class="back-button" + }} + {{i-con glyph="chevron-left" size=11}} + Secrets + {{/secret-link}} + +
+ {{yield}} +
+ +
+ {{#if (eq mode "create") }} + Create a secret at + + {{#if showPrefix}} + {{! need this to prevent a shift in the layout before we transition when saving }} + {{#if key.isCreating}} + {{key.initialParentKey}} + {{else}} + {{key.parentKey}} + {{/if}} + {{/if}} + + {{/if}} + + {{#if (eq mode "edit") }} + Edit + {{/if}} + + {{title}} +
+
`, +}); diff --git a/ui/app/components/secret-link.js b/ui/app/components/secret-link.js new file mode 100644 index 000000000..70fa30827 --- /dev/null +++ b/ui/app/components/secret-link.js @@ -0,0 +1,43 @@ +import Ember from 'ember'; +import hbs from 'htmlbars-inline-precompile'; +import { hrefTo } from 'vault/helpers/href-to'; +const { computed } = Ember; + +export function linkParams({ mode, secret, queryParams }) { + let params; + const route = `vault.cluster.secrets.backend.${mode}`; + + if (!secret || secret === ' ') { + params = [route + '-root']; + } else { + params = [route, secret]; + } + + if (queryParams) { + params.push(queryParams); + } + + return params; +} + +export default Ember.Component.extend({ + mode: 'list', + + secret: null, + queryParams: null, + ariaLabel: null, + + linkParams: computed('mode', 'secret', 'queryParams', function() { + return linkParams(this.getProperties('mode', 'secret', 'queryParams')); + }), + + attributeBindings: ['href', 'aria-label:ariaLabel'], + + href: computed('linkParams', function() { + return hrefTo(this, ...this.get('linkParams')); + }), + + layout: hbs`{{yield}}`, + + tagName: 'a', +}); diff --git a/ui/app/components/section-tabs.js b/ui/app/components/section-tabs.js new file mode 100644 index 000000000..c90cb5503 --- /dev/null +++ b/ui/app/components/section-tabs.js @@ -0,0 +1,14 @@ +import Ember from 'ember'; + +const SectionTabs = Ember.Component.extend({ + tagName: '', + + model: null, + tabType: 'authSettings', +}); + +SectionTabs.reopenClass({ + positionalParams: ['model', 'tabType'], +}); + +export default SectionTabs; diff --git a/ui/app/components/shamir-flow.js b/ui/app/components/shamir-flow.js new file mode 100644 index 000000000..5df2bc38d --- /dev/null +++ b/ui/app/components/shamir-flow.js @@ -0,0 +1,142 @@ +import Ember from 'ember'; + +const { Component, inject, computed, get } = Ember; +const { camelize } = Ember.String; + +const DEFAULTS = { + key: null, + loading: false, + errors: [], + threshold: null, + progress: null, + started: false, + generateWithPGP: false, + pgpKeyFile: { value: '' }, + nonce: '', +}; + +export default Component.extend(DEFAULTS, { + tagName: '', + store: inject.service(), + formText: null, + fetchOnInit: false, + buttonText: 'Submit', + thresholdPath: 'required', + generateAction: false, + encoded_token: null, + + init() { + if (this.get('fetchOnInit')) { + this.attemptProgress(); + } + return this._super(...arguments); + }, + + onShamirSuccess: _ => _, + // can be overridden w/an attr + isComplete(data) { + return data.complete === true; + }, + + stopLoading() { + this.setProperties({ + loading: false, + errors: [], + key: null, + }); + }, + + reset() { + this.setProperties(DEFAULTS); + }, + + hasProgress: computed.gt('progress', 0), + + actionSuccess(resp) { + const { isComplete, onShamirSuccess, thresholdPath } = this.getProperties( + 'isComplete', + 'onShamirSuccess', + 'thresholdPath' + ); + this.stopLoading(); + this.set('threshold', get(resp, thresholdPath)); + this.setProperties(resp); + if (isComplete(resp)) { + this.reset(); + onShamirSuccess(resp); + } + }, + + actionError(e) { + this.stopLoading(); + if (e.httpStatus === 400) { + this.set('errors', e.errors); + } else { + throw e; + } + }, + + extractData(data) { + const isGenerate = this.get('generateAction'); + const hasStarted = this.get('started'); + const usePGP = this.get('generateWithPGP'); + const nonce = this.get('nonce'); + + if (!isGenerate || hasStarted) { + if (nonce) { + data.nonce = nonce; + } + return data; + } + + if (usePGP) { + return { + pgp_key: data.pgp_key, + }; + } + + return { + otp: data.otp, + }; + }, + + attemptProgress(data) { + const checkStatus = data ? false : true; + let action = this.get('action'); + action = action && camelize(action); + this.set('loading', true); + const adapter = this.get('store').adapterFor('cluster'); + const method = adapter[action]; + method + .call(adapter, data, { checkStatus }) + .then(resp => this.actionSuccess(resp), (...args) => this.actionError(...args)); + }, + + actions: { + onSubmit(data) { + if (!data.key) { + return; + } + this.attemptProgress(this.extractData(data)); + }, + + startGenerate(data) { + this.attemptProgress(this.extractData(data)); + }, + + generateOTP() { + const bytes = new window.Uint8Array(16); + window.crypto.getRandomValues(bytes); + this.set('otp', base64js.fromByteArray(bytes)); + }, + + setKey(_, keyFile) { + this.set('pgp_key', keyFile.value); + this.set('pgpKeyFile', keyFile); + }, + + clearToken() { + this.set('encoded_token', null); + }, + }, +}); diff --git a/ui/app/components/shamir-progress.js b/ui/app/components/shamir-progress.js new file mode 100644 index 000000000..c470cd0a1 --- /dev/null +++ b/ui/app/components/shamir-progress.js @@ -0,0 +1,14 @@ +import Ember from 'ember'; + +export default Ember.Component.extend({ + threshold: null, + progress: null, + classNames: ['shamir-progress'], + progressPercent: Ember.computed('threshold', 'progress', function() { + const { threshold, progress } = this.getProperties('threshold', 'progress'); + if (threshold && progress) { + return progress / threshold * 100; + } + return 0; + }), +}); diff --git a/ui/app/components/splash-page.js b/ui/app/components/splash-page.js new file mode 100644 index 000000000..e3ac4fb5c --- /dev/null +++ b/ui/app/components/splash-page.js @@ -0,0 +1,5 @@ +import Ember from 'ember'; + +export default Ember.Component.extend({ + tagName: '', +}); diff --git a/ui/app/components/splash-page/splash-content.js b/ui/app/components/splash-page/splash-content.js new file mode 100644 index 000000000..e3ac4fb5c --- /dev/null +++ b/ui/app/components/splash-page/splash-content.js @@ -0,0 +1,5 @@ +import Ember from 'ember'; + +export default Ember.Component.extend({ + tagName: '', +}); diff --git a/ui/app/components/splash-page/splash-footer.js b/ui/app/components/splash-page/splash-footer.js new file mode 100644 index 000000000..e3ac4fb5c --- /dev/null +++ b/ui/app/components/splash-page/splash-footer.js @@ -0,0 +1,5 @@ +import Ember from 'ember'; + +export default Ember.Component.extend({ + tagName: '', +}); diff --git a/ui/app/components/splash-page/splash-header.js b/ui/app/components/splash-page/splash-header.js new file mode 100644 index 000000000..e3ac4fb5c --- /dev/null +++ b/ui/app/components/splash-page/splash-header.js @@ -0,0 +1,5 @@ +import Ember from 'ember'; + +export default Ember.Component.extend({ + tagName: '', +}); diff --git a/ui/app/components/status-menu.js b/ui/app/components/status-menu.js new file mode 100644 index 000000000..79928a285 --- /dev/null +++ b/ui/app/components/status-menu.js @@ -0,0 +1,21 @@ +import Ember from 'ember'; + +const { inject, computed } = Ember; + +export default Ember.Component.extend({ + currentCluster: inject.service('current-cluster'), + cluster: computed.alias('currentCluster.cluster'), + auth: inject.service(), + type: 'cluster', + partialName: computed('type', function() { + return `partials/status/${this.get('type')}`; + }), + glyphName: computed('type', function() { + const glyphs = { + cluster: 'unlocked', + user: 'android-person', + replication: 'replication', + }; + return glyphs[this.get('type')]; + }), +}); diff --git a/ui/app/components/string-list.js b/ui/app/components/string-list.js new file mode 100644 index 000000000..277200987 --- /dev/null +++ b/ui/app/components/string-list.js @@ -0,0 +1,130 @@ +import Ember from 'ember'; + +const { computed, set } = Ember; + +export default Ember.Component.extend({ + 'data-test-component': 'string-list', + classNames: ['field', 'string-list', 'form-section'], + + /* + * @public + * @param String + * + * Optional - Text displayed in the header above all of the inputs + * + */ + label: null, + + /* + * @public + * @param Function + * + * Function called when any of the inputs change + * accepts a single param `value` that is the + * result of calling `toVal()`. + * + */ + onChange: () => {}, + + /* + * @public + * @param String | Array + * A comma-separated string or an array of strings. + * Defaults to an empty array. + * + */ + inputValue: [], + + /* + * + * @public + * @param String - ['array'|'string] + * + * Optional type for `inputValue` - defaults to `'array'` + * Needs to match type of `inputValue` because it is set by the component on init. + * + */ + type: 'array', + + /* + * + * @private + * @param Ember.ArrayProxy + * + * mutable array that contains objects in the form of + * { + * value: 'somestring', + * } + * + * used to track the state of values bound to the various inputs + * + */ + inputList: computed(function() { + return Ember.ArrayProxy.create({ + content: [], + // trim the `value` when accessing objects + objectAtContent: function(idx) { + const obj = this.get('content').objectAt(idx); + if (obj && obj.value) { + set(obj, 'value', obj.value.trim()); + } + return obj; + }, + }); + }), + + init() { + this._super(...arguments); + this.setType(); + this.toList(); + this.send('addInput'); + }, + + setType() { + const list = this.get('inputList'); + if (!list) { + return; + } + this.set('type', typeof list); + }, + + toVal() { + const inputs = this.get('inputList').filter(x => x.value).mapBy('value'); + if (this.get('format') === 'string') { + return inputs.join(','); + } + return inputs; + }, + + toList() { + let input = this.get('inputValue') || []; + const inputList = this.get('inputList'); + if (typeof input === 'string') { + input = input.split(','); + } + inputList.addObjects(input.map(value => ({ value }))); + }, + + actions: { + inputChanged(idx, val) { + const inputObj = this.get('inputList').objectAt(idx); + const onChange = this.get('onChange'); + set(inputObj, 'value', val); + onChange(this.toVal()); + }, + + addInput() { + const inputList = this.get('inputList'); + if (inputList.get('lastObject.value') !== '') { + inputList.pushObject({ value: '' }); + } + }, + + removeInput(idx) { + const onChange = this.get('onChange'); + const inputs = this.get('inputList'); + inputs.removeObject(inputs.objectAt(idx)); + onChange(this.toVal()); + }, + }, +}); diff --git a/ui/app/components/text-file.js b/ui/app/components/text-file.js new file mode 100644 index 000000000..eba0e4d6e --- /dev/null +++ b/ui/app/components/text-file.js @@ -0,0 +1,85 @@ +import Ember from 'ember'; + +const { set } = Ember; + +export default Ember.Component.extend({ + 'data-test-component': 'text-file', + classNames: ['box', 'is-fullwidth', 'is-marginless', 'is-shadowless'], + classNameBindings: ['inputOnly:is-paddingless'], + + /* + * @public + * @param Object + * Object in the shape of: + * { + * value: 'file contents here', + * fileName: 'nameOfFile.txt', + * enterAsText: bool + * } + */ + file: null, + + index: null, + onChange: () => {}, + + /* + * @public + * @param Boolean + * When true, only the file input will be rendered + */ + inputOnly: false, + + /* + * @public + * @param String + * Text to use as the label for the file input + * If null, a default will be rendered + */ + label: null, + + /* + * @public + * @param String + * Text to use as help under the file input + * If null, a default will be rendered + */ + fileHelpText: 'Select a file from your computer', + + /* + * @public + * @param String + * Text to use as help under the textarea in text-input mode + * If null, a default will be rendered + */ + textareaHelpText: 'Enter the value as text', + + readFile(file) { + const reader = new FileReader(); + reader.onload = () => this.setFile(reader.result, file.name); + reader.readAsText(file); + }, + + setFile(contents, filename) { + this.get('onChange')(this.get('index'), { value: contents, fileName: filename }); + }, + + actions: { + pickedFile(e) { + const { files } = e.target; + if (!files.length) { + return; + } + for (let i = 0, len = files.length; i < len; i++) { + this.readFile(files[i]); + } + }, + updateData(e) { + const file = this.get('file'); + set(file, 'value', e.target.value); + this.get('onChange')(this.get('index'), this.get('file')); + }, + clearFile() { + this.get('onChange')(this.get('index'), { value: '' }); + }, + }, +}); diff --git a/ui/app/components/toggle-button.js b/ui/app/components/toggle-button.js new file mode 100644 index 000000000..56c68413a --- /dev/null +++ b/ui/app/components/toggle-button.js @@ -0,0 +1,34 @@ +import Ember from 'ember'; + +const { get, set } = Ember; + +export default Ember.Component.extend({ + tagName: 'button', + type: 'button', + toggleTarget: null, + toggleAttr: null, + classNameBindings: ['buttonClass'], + attributeBindings: ['type'], + buttonClass: 'has-text-info', + classNames: ['button', 'is-transparent'], + openLabel: 'Hide options', + closedLabel: 'More options', + init() { + this._super(...arguments); + const toggleAttr = this.get('toggleAttr'); + Ember.defineProperty( + this, + 'isOpen', + Ember.computed(`toggleTarget.${toggleAttr}`, () => { + const props = this.getProperties('toggleTarget', 'toggleAttr'); + return Ember.get(props.toggleTarget, props.toggleAttr); + }) + ); + }, + click() { + const target = this.get('toggleTarget'); + const attr = this.get('toggleAttr'); + const current = get(target, attr); + set(target, attr, !current); + }, +}); diff --git a/ui/app/components/token-expire-warning.js b/ui/app/components/token-expire-warning.js new file mode 100644 index 000000000..d17911572 --- /dev/null +++ b/ui/app/components/token-expire-warning.js @@ -0,0 +1,31 @@ +import Ember from 'ember'; + +export default Ember.Component.extend({ + auth: Ember.inject.service(), + + routing: Ember.inject.service('-routing'), + + transitionToRoute: function() { + var router = this.get('routing.router'); + router.transitionTo.apply(router, arguments); + }, + + isDismissed: false, + + actions: { + reauthenticate() { + this.get('auth').deleteCurrentToken(); + this.transitionToRoute('vault.cluster'); + }, + + renewToken() { + const auth = this.get('auth'); + auth.renew(); + auth.setLastFetch(Date.now()); + }, + + dismiss() { + this.set('isDismissed', true); + }, + }, +}); diff --git a/ui/app/components/tool-actions-form.js b/ui/app/components/tool-actions-form.js new file mode 100644 index 000000000..c89cc4191 --- /dev/null +++ b/ui/app/components/tool-actions-form.js @@ -0,0 +1,134 @@ +import Ember from 'ember'; +import moment from 'moment'; + +const { get, set, computed, setProperties } = Ember; + +const DEFAULTS = { + token: null, + rewrap_token: null, + errors: [], + wrap_info: null, + creation_time: null, + creation_ttl: null, + data: '{\n}', + unwrap_data: null, + wrapTTL: null, + sum: null, + random_bytes: null, + input: null, +}; + +const WRAPPING_ENDPOINTS = ['lookup', 'wrap', 'unwrap', 'rewrap']; + +export default Ember.Component.extend(DEFAULTS, { + store: Ember.inject.service(), + // putting these attrs here so they don't get reset when you click back + //random + bytes: 32, + //hash + format: 'base64', + algorithm: 'sha2-256', + + tagName: '', + + didReceiveAttrs() { + this._super(...arguments); + this.checkAction(); + }, + + selectedAction: null, + + reset() { + if (this.isDestroyed || this.isDestroying) { + return; + } + setProperties(this, DEFAULTS); + }, + + checkAction() { + const currentAction = get(this, 'selectedAction'); + const oldAction = get(this, 'oldSelectedAction'); + + if (currentAction !== oldAction) { + this.reset(); + } + set(this, 'oldSelectedAction', currentAction); + }, + + dataIsEmpty: computed.match('data', new RegExp(DEFAULTS.data)), + + expirationDate: computed('creation_time', 'creation_ttl', function() { + const { creation_time, creation_ttl } = this.getProperties('creation_time', 'creation_ttl'); + if (!(creation_time && creation_ttl)) { + return null; + } + return moment(creation_time).add(moment.duration(creation_ttl, 'seconds')); + }), + + handleError(e) { + set(this, 'errors', e.errors); + }, + + handleSuccess(resp, action) { + let props = {}; + if (resp && resp.data && action === 'unwrap') { + props = Ember.assign({}, props, { unwrap_data: resp.data }); + } + props = Ember.assign({}, props, resp.data); + + if (resp && resp.wrap_info) { + const keyName = action === 'rewrap' ? 'rewrap_token' : 'token'; + props = Ember.assign({}, props, { [keyName]: resp.wrap_info.token }); + } + setProperties(this, props); + }, + + getData() { + const action = get(this, 'selectedAction'); + if (WRAPPING_ENDPOINTS.includes(action)) { + return get(this, 'dataIsEmpty') + ? { token: (get(this, 'token') || '').trim() } + : JSON.parse(get(this, 'data')); + } + if (action === 'random') { + return this.getProperties('bytes'); + } + + if (action === 'hash') { + return this.getProperties('input', 'format', 'algorithm'); + } + }, + + actions: { + doSubmit(evt) { + evt.preventDefault(); + const action = get(this, 'selectedAction'); + const wrapTTL = action === 'wrap' ? get(this, 'wrapTTL') : null; + const data = this.getData(); + setProperties(this, { + errors: null, + wrap_info: null, + creation_time: null, + creation_ttl: null, + }); + + get(this, 'store') + .adapterFor('tools') + .toolAction(action, data, { wrapTTL }) + .then(resp => this.handleSuccess(resp, action), (...errArgs) => this.handleError(...errArgs)); + }, + + onClear() { + this.reset(); + }, + + codemirrorUpdated(val, codemirror) { + codemirror.performLint(); + const hasErrors = codemirror.state.lint.marked.length > 0; + setProperties(this, { + buttonDisabled: hasErrors, + data: val, + }); + }, + }, +}); diff --git a/ui/app/components/tool-tip.js b/ui/app/components/tool-tip.js new file mode 100644 index 000000000..6ea25baea --- /dev/null +++ b/ui/app/components/tool-tip.js @@ -0,0 +1,6 @@ +import HoverDropdown from 'ember-basic-dropdown-hover/components/basic-dropdown-hover'; + +export default HoverDropdown.extend({ + delay: 0, + horizontalPosition: 'auto-right', +}); diff --git a/ui/app/components/transit-edit.js b/ui/app/components/transit-edit.js new file mode 100644 index 000000000..a4a0b69a6 --- /dev/null +++ b/ui/app/components/transit-edit.js @@ -0,0 +1,116 @@ +import Ember from 'ember'; +import FocusOnInsertMixin from 'vault/mixins/focus-on-insert'; +import keys from 'vault/lib/keycodes'; + +const { get, set, computed } = Ember; +const LIST_ROOT_ROUTE = 'vault.cluster.secrets.backend.list-root'; +const SHOW_ROUTE = 'vault.cluster.secrets.backend.show'; + +export default Ember.Component.extend(FocusOnInsertMixin, { + mode: null, + onDataChange: null, + refresh: 'refresh', + key: null, + routing: Ember.inject.service('-routing'), + requestInFlight: computed.or('key.isLoading', 'key.isReloading', 'key.isSaving'), + willDestroyElement() { + const key = this.get('key'); + if (get(key, 'isError')) { + key.rollbackAttributes(); + } + }, + + transitionToRoute() { + const router = this.get('routing.router'); + router.transitionTo.apply(router, arguments); + }, + + bindKeys: Ember.on('didInsertElement', function() { + Ember.$(document).on('keyup.keyEdit', this.onEscape.bind(this)); + }), + + unbindKeys: Ember.on('willDestroyElement', function() { + Ember.$(document).off('keyup.keyEdit'); + }), + + onEscape(e) { + if (e.keyCode !== keys.ESC || this.get('mode') !== 'show') { + return; + } + this.transitionToRoute(LIST_ROOT_ROUTE); + }, + + hasDataChanges() { + get(this, 'onDataChange')(get(this, 'key.hasDirtyAttributes')); + }, + + persistKey(method, successCallback) { + const key = get(this, 'key'); + return key[method]().then(result => { + if (!Ember.get(result, 'didError')) { + successCallback(key); + } + }); + }, + + actions: { + handleKeyDown(_, e) { + e.stopPropagation(); + if (!(e.keyCode === keys.ENTER && e.metaKey)) { + return; + } + let $form = this.$('form'); + if ($form.length) { + $form.submit(); + } + $form = null; + }, + + createOrUpdateKey(type, event) { + event.preventDefault(); + + const keyId = this.get('key.id'); + // prevent from submitting if there's no key + // maybe do something fancier later + if (type === 'create' && Ember.isBlank(keyId)) { + return; + } + + this.persistKey( + 'save', + () => { + this.hasDataChanges(); + this.transitionToRoute(SHOW_ROUTE, keyId); + }, + type === 'create' + ); + }, + + handleChange() { + this.hasDataChanges(); + }, + + setValueOnKey(key, event) { + set(get(this, 'key'), key, event.target.checked); + }, + + derivedChange(val) { + get(this, 'key').setDerived(val); + }, + + convergentEncryptionChange(val) { + get(this, 'key').setConvergentEncryption(val); + }, + + refresh() { + this.sendAction('refresh'); + }, + + deleteKey() { + this.persistKey('destroyRecord', () => { + this.hasDataChanges(); + this.transitionToRoute(LIST_ROOT_ROUTE); + }); + }, + }, +}); diff --git a/ui/app/components/transit-key-actions.js b/ui/app/components/transit-key-actions.js new file mode 100644 index 000000000..b0a022672 --- /dev/null +++ b/ui/app/components/transit-key-actions.js @@ -0,0 +1,181 @@ +import Ember from 'ember'; +const { get, set } = Ember; + +const TRANSIT_PARAMS = { + algorithm: 'sha2-256', + bits: 256, + bytes: 32, + ciphertext: null, + context: null, + format: 'base64', + hmac: null, + input: null, + key_version: 0, + keys: null, + nonce: null, + param: 'wrapped', + prehashed: false, + plaintext: null, + random_bytes: null, + signature: null, + sum: null, + exportKeyType: null, + exportKeyVersion: null, + wrappedToken: null, + valid: null, + plaintextOriginal: null, + didDecode: false, +}; +const PARAMS_FOR_ACTION = { + sign: ['input', 'algorithm', 'key_version', 'prehashed'], + verify: ['input', 'hmac', 'signature', 'algorithm', 'prehashed'], + hmac: ['input', 'algorithm', 'key_version'], + encrypt: ['plaintext', 'context', 'nonce', 'key_version'], + decrypt: ['ciphertext', 'context', 'nonce'], + rewrap: ['ciphertext', 'context', 'nonce', 'key_version'], +}; +export default Ember.Component.extend(TRANSIT_PARAMS, { + store: Ember.inject.service(), + + // public attrs + selectedAction: null, + key: null, + + refresh: 'refresh', + + init() { + this._super(...arguments); + if (get(this, 'selectedAction')) { + return; + } + set(this, 'selectedAction', get(this, 'key.supportedActions.firstObject')); + Ember.assert('`key` is required for `' + this.toString() + '`.', this.getModelInfo()); + }, + + didReceiveAttrs() { + this._super(...arguments); + this.checkAction(); + if (get(this, 'selectedAction') === 'export') { + this.setExportKeyDefaults(); + } + }, + + setExportKeyDefaults() { + const exportKeyType = get(this, 'key.exportKeyTypes.firstObject'); + const exportKeyVersion = get(this, 'key.validKeyVersions.lastObject'); + this.setProperties({ + exportKeyType, + exportKeyVersion, + }); + }, + + getModelInfo() { + const model = get(this, 'key') || get(this, 'backend'); + if (!model) { + return null; + } + const backend = get(model, 'backend') || get(model, 'id'); + const id = get(model, 'id'); + + return { + backend, + id, + }; + }, + + checkAction() { + const currentAction = get(this, 'selectedAction'); + const oldAction = get(this, 'oldSelectedAction'); + + this.resetParams(oldAction, currentAction); + set(this, 'oldSelectedAction', currentAction); + }, + + resetParams(oldAction, action) { + let params = Ember.copy(TRANSIT_PARAMS); + let paramsToKeep; + let clearWithoutCheck = + !oldAction || + // don't save values from datakey + oldAction === 'datakey' || + // can rewrap signatures — using that as a ciphertext later would be problematic + (oldAction === 'rewrap' && !get(this, 'key.supportsEncryption')); + + if (!clearWithoutCheck && action) { + paramsToKeep = PARAMS_FOR_ACTION[action]; + } + + if (paramsToKeep) { + paramsToKeep.forEach(param => delete params[param]); + } + //resets params still left in the object to defaults + this.setProperties(params); + if (action === 'export') { + this.setExportKeyDefaults(); + } + }, + + handleError(e) { + this.set('errors', e.errors); + }, + + handleSuccess(resp, options, action) { + let props = {}; + if (resp && resp.data) { + if (action === 'export' && resp.data.keys) { + const { keys, type, name } = resp.data; + resp.data.keys = { keys, type, name }; + } + props = Ember.assign({}, props, resp.data); + } + if (options.wrapTTL) { + props = Ember.assign({}, props, { wrappedToken: resp.wrap_info.token }); + } + this.setProperties(props); + if (action === 'rotate') { + this.sendAction('refresh'); + } + }, + + compactData(data) { + return Object.keys(data).reduce((result, key) => { + if (data[key]) { + result[key] = data[key]; + } + return result; + }, {}); + }, + + actions: { + onActionChange(action) { + set(this, 'selectedAction', action); + this.checkAction(); + }, + + onClear() { + this.resetParams(null, get(this, 'selectedAction')); + }, + + clearParams(params) { + const arr = Array.isArray(params) ? params : [params]; + arr.forEach(param => this.set(param, null)); + }, + + doSubmit(data, options = {}) { + const { backend, id } = this.getModelInfo(); + const action = this.get('selectedAction'); + let payload = data ? this.compactData(data) : null; + this.setProperties({ + errors: null, + result: null, + }); + this.get('store') + .adapterFor('transit-key') + .keyAction(action, { backend, id, payload }, options) + .then( + resp => this.handleSuccess(resp, options, action), + (...errArgs) => this.handleError(...errArgs) + ); + }, + }, +}); diff --git a/ui/app/components/ttl-picker.js b/ui/app/components/ttl-picker.js new file mode 100644 index 000000000..c903ad8f4 --- /dev/null +++ b/ui/app/components/ttl-picker.js @@ -0,0 +1,79 @@ +import Ember from 'ember'; + +const { computed, get, set } = Ember; + +export default Ember.Component.extend({ + 'data-test-component': 'ttl-picker', + classNames: 'field', + setDefaultValue: true, + onChange: () => {}, + labelText: 'TTL', + labelClass: '', + time: 30, + unit: 'm', + initialValue: null, + unitOptions: [ + { label: 'seconds', value: 's' }, + { label: 'minutes', value: 'm' }, + { label: 'hours', value: 'h' }, + { label: 'days', value: 'd' }, + ], + + ouputSeconds: false, + + convertToSeconds(time, unit) { + const toSeconds = { + s: 1, + m: 60, + h: 3600, + }; + + return time * toSeconds[unit]; + }, + + TTL: computed('time', 'unit', function() { + let { time, unit, outputSeconds } = this.getProperties('time', 'unit', 'outputSeconds'); + //convert to hours + if (unit === 'd') { + time = time * 24; + unit = 'h'; + } + const timeString = time + unit; + return outputSeconds ? this.convertToSeconds(time, unit) : timeString; + }), + + didRender() { + this._super(...arguments); + if (get(this, 'setDefaultValue') === false) { + return; + } + get(this, 'onChange')(get(this, 'TTL')); + }, + + init() { + this._super(...arguments); + if (!get(this, 'onChange')) { + throw new Ember.Error('`onChange` handler is a required attr in `' + this.toString() + '`.'); + } + if (get(this, 'initialValue')) { + this.parseAndSetTime(); + } + }, + + parseAndSetTime() { + const value = get(this, 'initialValue'); + const seconds = Ember.typeOf(value) === 'number' ? value : Duration.parse(value).seconds(); + + this.set('time', seconds); + this.set('unit', 's'); + }, + + actions: { + changedValue(key, event) { + const { type, value, checked } = event.target; + const val = type === 'checkbox' ? checked : value; + set(this, key, val); + get(this, 'onChange')(get(this, 'TTL')); + }, + }, +}); diff --git a/ui/app/components/upgrade-link.js b/ui/app/components/upgrade-link.js new file mode 100644 index 000000000..979838c68 --- /dev/null +++ b/ui/app/components/upgrade-link.js @@ -0,0 +1,29 @@ +import Ember from 'ember'; + +export default Ember.Component.extend({ + isAnimated: false, + isActive: false, + tagName: 'span', + actions: { + openOverlay() { + this.set('isActive', true); + Ember.run.later( + this, + function() { + this.set('isAnimated', true); + }, + 10 + ); + }, + closeOverlay() { + this.set('isAnimated', false); + Ember.run.later( + this, + function() { + this.set('isActive', false); + }, + 300 + ); + }, + }, +}); diff --git a/ui/app/components/upgrade-page.js b/ui/app/components/upgrade-page.js new file mode 100644 index 000000000..590b55dfd --- /dev/null +++ b/ui/app/components/upgrade-page.js @@ -0,0 +1,12 @@ +import Ember from 'ember'; + +const { computed } = Ember; + +export default Ember.Component.extend({ + title: 'Vault Enterprise', + featureName: computed('title', function() { + let title = this.get('title'); + return title === 'Vault Enterprise' ? 'This' : title; + }), + minimumEdition: 'Vault Enterprise', +}); diff --git a/ui/app/components/wrap-ttl.js b/ui/app/components/wrap-ttl.js new file mode 100644 index 000000000..7e7402a3e --- /dev/null +++ b/ui/app/components/wrap-ttl.js @@ -0,0 +1,60 @@ +import Ember from 'ember'; +import hbs from 'htmlbars-inline-precompile'; + +const { computed, get, set } = Ember; + +export default Ember.Component.extend({ + // passed from outside + onChange: null, + wrapResponse: true, + + ttl: null, + + wrapTTL: computed('wrapResponse', 'ttl', function() { + const { wrapResponse, ttl } = this.getProperties('wrapResponse', 'ttl'); + return wrapResponse ? ttl : null; + }), + + didRender() { + this._super(...arguments); + get(this, 'onChange')(get(this, 'wrapTTL')); + }, + + init() { + this._super(...arguments); + Ember.assert( + '`onChange` handler is a required attr in `' + this.toString() + '`.', + get(this, 'onChange') + ); + }, + + layout: hbs` +
+
+ + +
+ {{#if wrapResponse}} + {{ttl-picker data-test-wrap-ttl-picker=true labelText='Wrap TTL' onChange=(action (mut ttl))}} + {{/if}} +
+ `, + + actions: { + changedValue(key, event) { + const { type, value, checked } = event.target; + const val = type === 'checkbox' ? checked : value; + set(this, key, val); + get(this, 'onChange')(get(this, 'wrapTTL')); + }, + }, +}); diff --git a/ui/app/controllers/.gitkeep b/ui/app/controllers/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/ui/app/controllers/application.js b/ui/app/controllers/application.js new file mode 100644 index 000000000..5f6f6c9a9 --- /dev/null +++ b/ui/app/controllers/application.js @@ -0,0 +1,33 @@ +import Ember from 'ember'; +import config from '../config/environment'; + +export default Ember.Controller.extend({ + env: config.environment, + auth: Ember.inject.service(), + vaultVersion: Ember.inject.service('version'), + activeCluster: Ember.computed('auth.activeCluster', function() { + return this.store.peekRecord('cluster', this.get('auth.activeCluster')); + }), + activeClusterName: Ember.computed('auth.activeCluster', function() { + const activeCluster = this.store.peekRecord('cluster', this.get('auth.activeCluster')); + return activeCluster ? activeCluster.get('name') : null; + }), + showNav: Ember.computed( + 'activeClusterName', + 'auth.currentToken', + 'activeCluster.dr.isSecondary', + 'activeCluster.{needsInit,sealed}', + function() { + if ( + this.get('activeCluster.dr.isSecondary') || + this.get('activeCluster.needsInit') || + this.get('activeCluster.sealed') + ) { + return false; + } + if (this.get('activeClusterName') && this.get('auth.currentToken')) { + return true; + } + } + ), +}); diff --git a/ui/app/controllers/vault/cluster.js b/ui/app/controllers/vault/cluster.js new file mode 100644 index 000000000..141426150 --- /dev/null +++ b/ui/app/controllers/vault/cluster.js @@ -0,0 +1,8 @@ +import Ember from 'ember'; + +const { inject, Controller } = Ember; + +export default Controller.extend({ + auth: inject.service(), + version: inject.service(), +}); diff --git a/ui/app/controllers/vault/cluster/access/identity/aliases/add.js b/ui/app/controllers/vault/cluster/access/identity/aliases/add.js new file mode 100644 index 000000000..e22b4416a --- /dev/null +++ b/ui/app/controllers/vault/cluster/access/identity/aliases/add.js @@ -0,0 +1,5 @@ +import CreateController from '../create'; + +export default CreateController.extend({ + showRoute: 'vault.cluster.access.identity.aliases.show', +}); diff --git a/ui/app/controllers/vault/cluster/access/identity/aliases/edit.js b/ui/app/controllers/vault/cluster/access/identity/aliases/edit.js new file mode 100644 index 000000000..dc06915f0 --- /dev/null +++ b/ui/app/controllers/vault/cluster/access/identity/aliases/edit.js @@ -0,0 +1,3 @@ +import CreateController from './add'; + +export default CreateController.extend(); diff --git a/ui/app/controllers/vault/cluster/access/identity/aliases/index.js b/ui/app/controllers/vault/cluster/access/identity/aliases/index.js new file mode 100644 index 000000000..fd2e72663 --- /dev/null +++ b/ui/app/controllers/vault/cluster/access/identity/aliases/index.js @@ -0,0 +1,4 @@ +import Ember from 'ember'; +import ListController from 'vault/mixins/list-controller'; + +export default Ember.Controller.extend(ListController); diff --git a/ui/app/controllers/vault/cluster/access/identity/create.js b/ui/app/controllers/vault/cluster/access/identity/create.js new file mode 100644 index 000000000..46d9d6437 --- /dev/null +++ b/ui/app/controllers/vault/cluster/access/identity/create.js @@ -0,0 +1,10 @@ +import Ember from 'ember'; +import { task } from 'ember-concurrency'; + +export default Ember.Controller.extend({ + showRoute: 'vault.cluster.access.identity.show', + showTab: 'details', + navToShow: task(function*(model) { + yield this.transitionToRoute(this.get('showRoute'), model.id, this.get('showTab')); + }), +}); diff --git a/ui/app/controllers/vault/cluster/access/identity/edit.js b/ui/app/controllers/vault/cluster/access/identity/edit.js new file mode 100644 index 000000000..43916a858 --- /dev/null +++ b/ui/app/controllers/vault/cluster/access/identity/edit.js @@ -0,0 +1,3 @@ +import CreateController from './create'; + +export default CreateController.extend(); diff --git a/ui/app/controllers/vault/cluster/access/identity/index.js b/ui/app/controllers/vault/cluster/access/identity/index.js new file mode 100644 index 000000000..fd2e72663 --- /dev/null +++ b/ui/app/controllers/vault/cluster/access/identity/index.js @@ -0,0 +1,4 @@ +import Ember from 'ember'; +import ListController from 'vault/mixins/list-controller'; + +export default Ember.Controller.extend(ListController); diff --git a/ui/app/controllers/vault/cluster/access/identity/merge.js b/ui/app/controllers/vault/cluster/access/identity/merge.js new file mode 100644 index 000000000..43916a858 --- /dev/null +++ b/ui/app/controllers/vault/cluster/access/identity/merge.js @@ -0,0 +1,3 @@ +import CreateController from './create'; + +export default CreateController.extend(); diff --git a/ui/app/controllers/vault/cluster/access/leases/index.js b/ui/app/controllers/vault/cluster/access/leases/index.js new file mode 100644 index 000000000..cbe007a32 --- /dev/null +++ b/ui/app/controllers/vault/cluster/access/leases/index.js @@ -0,0 +1,9 @@ +import Ember from 'ember'; + +export default Ember.Controller.extend({ + actions: { + lookupLease(id) { + this.transitionToRoute('vault.cluster.access.leases.show', id); + }, + }, +}); diff --git a/ui/app/controllers/vault/cluster/access/leases/list-root.js b/ui/app/controllers/vault/cluster/access/leases/list-root.js new file mode 100644 index 000000000..ea1fedd0a --- /dev/null +++ b/ui/app/controllers/vault/cluster/access/leases/list-root.js @@ -0,0 +1 @@ +export { default } from './list'; diff --git a/ui/app/controllers/vault/cluster/access/leases/list.js b/ui/app/controllers/vault/cluster/access/leases/list.js new file mode 100644 index 000000000..955192bbc --- /dev/null +++ b/ui/app/controllers/vault/cluster/access/leases/list.js @@ -0,0 +1,77 @@ +import Ember from 'ember'; +import utils from 'vault/lib/key-utils'; + +export default Ember.Controller.extend({ + flashMessages: Ember.inject.service(), + clusterController: Ember.inject.controller('vault.cluster'), + queryParams: { + page: 'page', + pageFilter: 'pageFilter', + }, + + page: 1, + pageFilter: null, + filter: null, + + backendCrumb: Ember.computed(function() { + return { + label: 'leases', + text: 'leases', + path: 'vault.cluster.access.leases.list-root', + model: this.get('clusterController.model.name'), + }; + }), + + isLoading: false, + + filterMatchesKey: Ember.computed('filter', 'model', 'model.[]', function() { + var filter = this.get('filter'); + var content = this.get('model'); + return !!(content.length && content.findBy('id', filter)); + }), + + firstPartialMatch: Ember.computed('filter', 'model', 'model.[]', 'filterMatchesKey', function() { + var filter = this.get('filter'); + var content = this.get('model'); + var filterMatchesKey = this.get('filterMatchesKey'); + var re = new RegExp('^' + filter); + return filterMatchesKey + ? null + : content.find(function(key) { + return re.test(key.get('id')); + }); + }), + + filterIsFolder: Ember.computed('filter', function() { + return !!utils.keyIsFolder(this.get('filter')); + }), + + actions: { + setFilter(val) { + this.set('filter', val); + }, + + setFilterFocus(bool) { + this.set('filterFocused', bool); + }, + + revokePrefix(prefix, isForce) { + const adapter = this.model.store.adapterFor('lease'); + const method = isForce ? 'forceRevokePrefix' : 'revokePrefix'; + const fn = adapter[method]; + fn + .call(adapter, prefix) + .then(() => { + return this.transitionToRoute('vault.cluster.access.leases.list-root').then(() => { + this.get('flashMessages').success(`All of the leases under ${prefix} will be revoked.`); + }); + }) + .catch(e => { + const errString = e.errors.join('.'); + this.get('flashMessages').danger( + `There was an error attempting to revoke the prefix: ${prefix}. ${errString}.` + ); + }); + }, + }, +}); diff --git a/ui/app/controllers/vault/cluster/access/leases/show.js b/ui/app/controllers/vault/cluster/access/leases/show.js new file mode 100644 index 000000000..74bff6913 --- /dev/null +++ b/ui/app/controllers/vault/cluster/access/leases/show.js @@ -0,0 +1,42 @@ +import Ember from 'ember'; + +export default Ember.Controller.extend({ + clusterController: Ember.inject.controller('vault.cluster'), + + backendCrumb: Ember.computed(function() { + return { + label: 'leases', + text: 'leases', + path: 'vault.cluster.access.leases.list-root', + model: this.get('clusterController.model.name'), + }; + }), + + flashMessages: Ember.inject.service(), + + actions: { + revokeLease(model) { + return model.destroyRecord().then(() => { + return this.transitionToRoute('vault.cluster.access.leases.list-root'); + }); + }, + + renewLease(model, interval) { + const adapter = model.store.adapterFor('lease'); + const flash = this.get('flashMessages'); + adapter + .renew(model.id, interval) + .then(() => { + this.send('refreshModel'); + // lol this is terrible, but there's no way to get the promise from the route refresh + Ember.run.next(() => { + flash.success(`The lease ${model.id} was successfully renewed.`); + }); + }) + .catch(e => { + const errString = e.errors.join('.'); + flash.danger(`There was an error renewing the lease: ${errString}`); + }); + }, + }, +}); diff --git a/ui/app/controllers/vault/cluster/access/methods.js b/ui/app/controllers/vault/cluster/access/methods.js new file mode 100644 index 000000000..51ba59bb8 --- /dev/null +++ b/ui/app/controllers/vault/cluster/access/methods.js @@ -0,0 +1,25 @@ +import Ember from 'ember'; +import { task } from 'ember-concurrency'; + +export default Ember.Controller.extend({ + queryParams: { + page: 'page', + pageFilter: 'pageFilter', + }, + + page: 1, + pageFilter: null, + filter: null, + + disableMethod: task(function*(method) { + const { type, path } = method.getProperties('type', 'path'); + try { + yield method.destroyRecord(); + this.get('flashMessages').success(`The ${type} auth method at ${path} has been disabled.`); + } catch (err) { + this.get('flashMessages').danger( + `There was an error disabling auth method at ${path}: ${err.errors.join(' ')}.` + ); + } + }).drop(), +}); diff --git a/ui/app/controllers/vault/cluster/auth.js b/ui/app/controllers/vault/cluster/auth.js new file mode 100644 index 000000000..6cc7ac0af --- /dev/null +++ b/ui/app/controllers/vault/cluster/auth.js @@ -0,0 +1,11 @@ +import Ember from 'ember'; +import { supportedAuthBackends } from 'vault/helpers/supported-auth-backends'; + +export default Ember.Controller.extend({ + queryParams: ['with'], + with: Ember.computed(function() { + return supportedAuthBackends()[0].type; + }), + + redirectTo: null, +}); diff --git a/ui/app/controllers/vault/cluster/init.js b/ui/app/controllers/vault/cluster/init.js new file mode 100644 index 000000000..c04dd306d --- /dev/null +++ b/ui/app/controllers/vault/cluster/init.js @@ -0,0 +1,71 @@ +import Ember from 'ember'; + +const DEFAULTS = { + keyData: null, + secret_shares: null, + secret_threshold: null, + pgp_keys: null, + use_pgp: false, + loading: false, +}; + +export default Ember.Controller.extend(DEFAULTS, { + reset() { + this.setProperties(DEFAULTS); + }, + + initSuccess(resp) { + this.set('loading', false); + this.set('keyData', resp); + }, + + initError(e) { + this.set('loading', false); + if (e.httpStatus === 400) { + this.set('errors', e.errors); + } else { + throw e; + } + }, + + keyFilename: Ember.computed('model.name', function() { + return `vault-cluster-${this.get('model.name')}`; + }), + + actions: { + initCluster(data) { + if (data.secret_shares) { + data.secret_shares = parseInt(data.secret_shares); + } + if (data.secret_threshold) { + data.secret_threshold = parseInt(data.secret_threshold); + } + if (!data.use_pgp) { + delete data.pgp_keys; + } + if (!data.use_pgp_for_root) { + delete data.root_token_pgp_key; + } + + delete data.use_pgp; + delete data.use_pgp_for_root; + const store = this.model.store; + this.setProperties({ + loading: true, + errors: null, + }); + store + .adapterFor('cluster') + .initCluster(data) + .then(resp => this.initSuccess(resp), (...errArgs) => this.initError(...errArgs)); + }, + + setKeys(data) { + this.set('pgp_keys', data); + }, + + setRootKey([key]) { + this.set('root_token_pgp_key', key); + }, + }, +}); diff --git a/ui/app/controllers/vault/cluster/policies/create.js b/ui/app/controllers/vault/cluster/policies/create.js new file mode 100644 index 000000000..537d0d022 --- /dev/null +++ b/ui/app/controllers/vault/cluster/policies/create.js @@ -0,0 +1,19 @@ +import Ember from 'ember'; +import PolicyEditController from 'vault/mixins/policy-edit-controller'; + +export default Ember.Controller.extend(PolicyEditController, { + showFileUpload: false, + file: null, + + actions: { + setPolicyFromFile(index, fileInfo) { + let { value, fileName } = fileInfo; + let model = this.get('model'); + model.set('policy', value); + if (!model.get('name')) { + model.set('name', fileName); + } + this.set('showFileUpload', false); + }, + }, +}); diff --git a/ui/app/controllers/vault/cluster/policies/index.js b/ui/app/controllers/vault/cluster/policies/index.js new file mode 100644 index 000000000..8d02e0ad3 --- /dev/null +++ b/ui/app/controllers/vault/cluster/policies/index.js @@ -0,0 +1,68 @@ +import Ember from 'ember'; +let { inject } = Ember; + +export default Ember.Controller.extend({ + flashMessages: inject.service(), + + queryParams: { + page: 'page', + pageFilter: 'pageFilter', + }, + + filter: null, + page: 1, + pageFilter: null, + + filterFocused: false, + + // set via the route `loading` action + isLoading: false, + + filterMatchesKey: Ember.computed('filter', 'model', 'model.[]', function() { + var filter = this.get('filter'); + var content = this.get('model'); + return !!(content && content.length && content.findBy('id', filter)); + }), + + firstPartialMatch: Ember.computed('filter', 'model', 'model.[]', 'filterMatchesKey', function() { + var filter = this.get('filter'); + var content = this.get('model'); + if (!content) { + return; + } + var filterMatchesKey = this.get('filterMatchesKey'); + var re = new RegExp('^' + filter); + return filterMatchesKey + ? null + : content.find(function(key) { + return re.test(key.get('id')); + }); + }), + + actions: { + setFilter: function(val) { + this.set('filter', val); + }, + setFilterFocus: function(bool) { + this.set('filterFocused', bool); + }, + deletePolicy(model) { + let policyType = model.get('policyType'); + let name = model.id; + let flash = this.get('flashMessages'); + model + .destroyRecord() + .then(() => { + flash.success(`${policyType.toUpperCase()} policy "${name}" was successfully deleted.`); + // this will clear the dataset cache on the store + this.send('willTransition'); + }) + .catch(e => { + let errors = e.errors ? e.errors.join('') : e.message; + flash.danger( + `There was an error deleting the ${policyType.toUpperCase()} policy "${name}": ${errors}.` + ); + }); + }, + }, +}); diff --git a/ui/app/controllers/vault/cluster/policy/edit.js b/ui/app/controllers/vault/cluster/policy/edit.js new file mode 100644 index 000000000..93c6cefe0 --- /dev/null +++ b/ui/app/controllers/vault/cluster/policy/edit.js @@ -0,0 +1,4 @@ +import Ember from 'ember'; +import PolicyEditController from 'vault/mixins/policy-edit-controller'; + +export default Ember.Controller.extend(PolicyEditController); diff --git a/ui/app/controllers/vault/cluster/replication-dr-promote.js b/ui/app/controllers/vault/cluster/replication-dr-promote.js new file mode 100644 index 000000000..578007d98 --- /dev/null +++ b/ui/app/controllers/vault/cluster/replication-dr-promote.js @@ -0,0 +1,6 @@ +import Ember from 'ember'; + +export default Ember.Controller.extend({ + queryParams: ['action'], + action: '', +}); diff --git a/ui/app/controllers/vault/cluster/replication.js b/ui/app/controllers/vault/cluster/replication.js new file mode 100644 index 000000000..80a7e9d40 --- /dev/null +++ b/ui/app/controllers/vault/cluster/replication.js @@ -0,0 +1,126 @@ +import Ember from 'ember'; + +const DEFAULTS = { + token: null, + id: null, + loading: false, + errors: [], + showFilterConfig: false, + primary_api_addr: null, + primary_cluster_addr: null, + filterConfig: { + mode: 'whitelist', + paths: [], + }, +}; + +export default Ember.Controller.extend(DEFAULTS, { + store: Ember.inject.service(), + rm: Ember.inject.service('replication-mode'), + replicationMode: Ember.computed.alias('rm.mode'), + + submitError(e) { + if (e.errors) { + this.set('errors', e.errors); + } else { + throw e; + } + }, + + saveFilterConfig() { + const config = this.get('filterConfig'); + const id = this.get('id'); + config.id = id; + const configRecord = this.get('store').createRecord('mount-filter-config', config); + return configRecord.save().catch(e => this.submitError(e)); + }, + + reset() { + this.setProperties(DEFAULTS); + }, + + submitSuccess(resp, action) { + const cluster = this.get('model'); + const store = this.get('store'); + if (!cluster) { + return; + } + + if (resp && resp.wrap_info) { + this.set('token', resp.wrap_info.token); + } + if (action === 'secondary-token') { + this.setProperties({ + loading: false, + primary_api_addr: null, + primary_cluster_addr: null, + }); + return cluster; + } + this.reset(); + return store + .adapterFor('cluster') + .replicationStatus() + .then(status => { + return store.pushPayload('cluster', status); + }) + .finally(() => { + this.set('loading', false); + }); + }, + + submitHandler(action, clusterMode, data, event) { + const replicationMode = this.get('replicationMode'); + let saveFilterConfig; + if (event && event.preventDefault) { + event.preventDefault(); + } + if (data && Ember.isPresent(data.saveFilterConfig)) { + saveFilterConfig = data.saveFilterConfig; + delete data.saveFilterConfig; + } + this.setProperties({ + loading: true, + errors: [], + }); + if (data) { + data = Object.keys(data).reduce((newData, key) => { + var val = data[key]; + if (Ember.isPresent(val)) { + newData[key] = val; + } + return newData; + }, {}); + } + + return this.get('store') + .adapterFor('cluster') + .replicationAction(action, replicationMode, clusterMode, data) + .then( + resp => { + if (saveFilterConfig) { + return this.saveFilterConfig().then(() => { + return this.submitSuccess(resp, action, clusterMode); + }); + } else { + return this.submitSuccess(resp, action, clusterMode); + } + }, + (...args) => this.submitError(...args) + ); + }, + + actions: { + onSubmit(/*action, mode, data, event*/) { + return this.submitHandler(...arguments); + }, + + clear() { + this.reset(); + this.setProperties({ + token: null, + id: null, + }); + }, + }, +}); diff --git a/ui/app/controllers/vault/cluster/replication/index.js b/ui/app/controllers/vault/cluster/replication/index.js new file mode 100644 index 000000000..4966bce8f --- /dev/null +++ b/ui/app/controllers/vault/cluster/replication/index.js @@ -0,0 +1,3 @@ +import Controller from './replication-mode'; + +export default Controller.extend(); diff --git a/ui/app/controllers/vault/cluster/replication/mode.js b/ui/app/controllers/vault/cluster/replication/mode.js new file mode 100644 index 000000000..4966bce8f --- /dev/null +++ b/ui/app/controllers/vault/cluster/replication/mode.js @@ -0,0 +1,3 @@ +import Controller from './replication-mode'; + +export default Controller.extend(); diff --git a/ui/app/controllers/vault/cluster/replication/mode/index.js b/ui/app/controllers/vault/cluster/replication/mode/index.js new file mode 100644 index 000000000..b0d0fc2ee --- /dev/null +++ b/ui/app/controllers/vault/cluster/replication/mode/index.js @@ -0,0 +1,3 @@ +import Controller from '../replication-mode'; + +export default Controller.extend(); diff --git a/ui/app/controllers/vault/cluster/replication/mode/manage.js b/ui/app/controllers/vault/cluster/replication/mode/manage.js new file mode 100644 index 000000000..b0d0fc2ee --- /dev/null +++ b/ui/app/controllers/vault/cluster/replication/mode/manage.js @@ -0,0 +1,3 @@ +import Controller from '../replication-mode'; + +export default Controller.extend(); diff --git a/ui/app/controllers/vault/cluster/replication/mode/secondaries.js b/ui/app/controllers/vault/cluster/replication/mode/secondaries.js new file mode 100644 index 000000000..b0d0fc2ee --- /dev/null +++ b/ui/app/controllers/vault/cluster/replication/mode/secondaries.js @@ -0,0 +1,3 @@ +import Controller from '../replication-mode'; + +export default Controller.extend(); diff --git a/ui/app/controllers/vault/cluster/replication/mode/secondaries/add.js b/ui/app/controllers/vault/cluster/replication/mode/secondaries/add.js new file mode 100644 index 000000000..01da58dff --- /dev/null +++ b/ui/app/controllers/vault/cluster/replication/mode/secondaries/add.js @@ -0,0 +1,3 @@ +import ReplicationController from '../../../replication'; + +export default ReplicationController.extend(); diff --git a/ui/app/controllers/vault/cluster/replication/mode/secondaries/config-create.js b/ui/app/controllers/vault/cluster/replication/mode/secondaries/config-create.js new file mode 100644 index 000000000..092ead178 --- /dev/null +++ b/ui/app/controllers/vault/cluster/replication/mode/secondaries/config-create.js @@ -0,0 +1 @@ +export { default } from './config-edit'; diff --git a/ui/app/controllers/vault/cluster/replication/mode/secondaries/config-edit.js b/ui/app/controllers/vault/cluster/replication/mode/secondaries/config-edit.js new file mode 100644 index 000000000..4ff135647 --- /dev/null +++ b/ui/app/controllers/vault/cluster/replication/mode/secondaries/config-edit.js @@ -0,0 +1,52 @@ +import Ember from 'ember'; + +const CONFIG_DEFAULTS = { + mode: 'whitelist', + paths: [], +}; + +export default Ember.Controller.extend({ + flashMessages: Ember.inject.service(), + rm: Ember.inject.service('replication-mode'), + replicationMode: Ember.computed.alias('rm.mode'), + actions: { + resetConfig(config) { + if (config.get('isNew')) { + config.setProperties(CONFIG_DEFAULTS); + } else { + config.rollbackAttributes(); + } + }, + + saveConfig(config, isDelete) { + const flash = this.get('flashMessages'); + const id = config.id; + const redirectArgs = isDelete + ? [ + 'vault.cluster.replication.mode.secondaries', + this.model.cluster.get('name'), + this.get('replicationMode'), + ] + : ['vault.cluster.replication.mode.secondaries.config-show', id]; + const modelMethod = isDelete ? config.destroyRecord : config.save; + + modelMethod + .call(config) + .then(() => { + this.transitionToRoute(...redirectArgs).followRedirects().then(() => { + flash.success( + `The performance mount filter config for the secondary ${id} was successfully ${isDelete + ? 'deleted' + : 'saved'}.` + ); + }); + }) + .catch(e => { + const errString = e.errors.join('.'); + flash.error( + `There was an error ${isDelete ? 'deleting' : 'saving'} the config for ${id}: ${errString}` + ); + }); + }, + }, +}); diff --git a/ui/app/controllers/vault/cluster/replication/mode/secondaries/config-show.js b/ui/app/controllers/vault/cluster/replication/mode/secondaries/config-show.js new file mode 100644 index 000000000..f0dddae6f --- /dev/null +++ b/ui/app/controllers/vault/cluster/replication/mode/secondaries/config-show.js @@ -0,0 +1,6 @@ +import Ember from 'ember'; + +export default Ember.Controller.extend({ + rm: Ember.inject.service('replication-mode'), + replicationMode: Ember.computed.alias('rm.mode'), +}); diff --git a/ui/app/controllers/vault/cluster/replication/mode/secondaries/index.js b/ui/app/controllers/vault/cluster/replication/mode/secondaries/index.js new file mode 100644 index 000000000..01da58dff --- /dev/null +++ b/ui/app/controllers/vault/cluster/replication/mode/secondaries/index.js @@ -0,0 +1,3 @@ +import ReplicationController from '../../../replication'; + +export default ReplicationController.extend(); diff --git a/ui/app/controllers/vault/cluster/replication/mode/secondaries/revoke.js b/ui/app/controllers/vault/cluster/replication/mode/secondaries/revoke.js new file mode 100644 index 000000000..9be89e9d3 --- /dev/null +++ b/ui/app/controllers/vault/cluster/replication/mode/secondaries/revoke.js @@ -0,0 +1 @@ +export { default } from '../../../replication'; diff --git a/ui/app/controllers/vault/cluster/replication/replication-mode.js b/ui/app/controllers/vault/cluster/replication/replication-mode.js new file mode 100644 index 000000000..f0dddae6f --- /dev/null +++ b/ui/app/controllers/vault/cluster/replication/replication-mode.js @@ -0,0 +1,6 @@ +import Ember from 'ember'; + +export default Ember.Controller.extend({ + rm: Ember.inject.service('replication-mode'), + replicationMode: Ember.computed.alias('rm.mode'), +}); diff --git a/ui/app/controllers/vault/cluster/response-wrapping.js b/ui/app/controllers/vault/cluster/response-wrapping.js new file mode 100644 index 000000000..612c01b88 --- /dev/null +++ b/ui/app/controllers/vault/cluster/response-wrapping.js @@ -0,0 +1,9 @@ +import Ember from 'ember'; + +export default Ember.Controller.extend({ + queryParams: { + selectedAction: 'action', + }, + + selectedAction: 'wrap', +}); diff --git a/ui/app/controllers/vault/cluster/secrets/backend/actions-root.js b/ui/app/controllers/vault/cluster/secrets/backend/actions-root.js new file mode 100644 index 000000000..0b230db57 --- /dev/null +++ b/ui/app/controllers/vault/cluster/secrets/backend/actions-root.js @@ -0,0 +1 @@ +export { default } from './actions'; diff --git a/ui/app/controllers/vault/cluster/secrets/backend/actions.js b/ui/app/controllers/vault/cluster/secrets/backend/actions.js new file mode 100644 index 000000000..81393e4b6 --- /dev/null +++ b/ui/app/controllers/vault/cluster/secrets/backend/actions.js @@ -0,0 +1,20 @@ +import Ember from 'ember'; +import BackendCrumbMixin from 'vault/mixins/backend-crumb'; + +export default Ember.Controller.extend(BackendCrumbMixin, { + queryParams: { + selectedAction: 'action', + }, + + actions: { + refresh: function() { + // closure actions don't bubble to routes, + // so we have to manually bubble here + this.send('refreshModel'); + }, + + hasChanges(hasChanges) { + this.send('hasDataChanges', hasChanges); + }, + }, +}); diff --git a/ui/app/controllers/vault/cluster/secrets/backend/create-root.js b/ui/app/controllers/vault/cluster/secrets/backend/create-root.js new file mode 100644 index 000000000..28d5c8ccc --- /dev/null +++ b/ui/app/controllers/vault/cluster/secrets/backend/create-root.js @@ -0,0 +1 @@ +export { default } from './create'; diff --git a/ui/app/controllers/vault/cluster/secrets/backend/create.js b/ui/app/controllers/vault/cluster/secrets/backend/create.js new file mode 100644 index 000000000..383aaa025 --- /dev/null +++ b/ui/app/controllers/vault/cluster/secrets/backend/create.js @@ -0,0 +1,17 @@ +import Ember from 'ember'; +import BackendCrumbMixin from 'vault/mixins/backend-crumb'; + +export default Ember.Controller.extend(BackendCrumbMixin, { + queryParams: ['initialKey'], + + initialKey: '', + + actions: { + refresh: function() { + this.send('refreshModel'); + }, + hasChanges(hasChanges) { + this.send('hasDataChanges', hasChanges); + }, + }, +}); diff --git a/ui/app/controllers/vault/cluster/secrets/backend/credentials-root.js b/ui/app/controllers/vault/cluster/secrets/backend/credentials-root.js new file mode 100644 index 000000000..cbf926c3a --- /dev/null +++ b/ui/app/controllers/vault/cluster/secrets/backend/credentials-root.js @@ -0,0 +1 @@ +export { default } from './credentials'; diff --git a/ui/app/controllers/vault/cluster/secrets/backend/credentials.js b/ui/app/controllers/vault/cluster/secrets/backend/credentials.js new file mode 100644 index 000000000..bb0f79adf --- /dev/null +++ b/ui/app/controllers/vault/cluster/secrets/backend/credentials.js @@ -0,0 +1,9 @@ +import Ember from 'ember'; + +export default Ember.Controller.extend({ + queryParams: ['action'], + action: '', + reset() { + this.set('action', ''); + }, +}); diff --git a/ui/app/controllers/vault/cluster/secrets/backend/edit-root.js b/ui/app/controllers/vault/cluster/secrets/backend/edit-root.js new file mode 100644 index 000000000..58cfd6a1c --- /dev/null +++ b/ui/app/controllers/vault/cluster/secrets/backend/edit-root.js @@ -0,0 +1 @@ +export { default } from './edit'; diff --git a/ui/app/controllers/vault/cluster/secrets/backend/edit.js b/ui/app/controllers/vault/cluster/secrets/backend/edit.js new file mode 100644 index 000000000..35e181111 --- /dev/null +++ b/ui/app/controllers/vault/cluster/secrets/backend/edit.js @@ -0,0 +1,16 @@ +import Ember from 'ember'; +import BackendCrumbMixin from 'vault/mixins/backend-crumb'; + +export default Ember.Controller.extend(BackendCrumbMixin, { + actions: { + refresh: function() { + // closure actions don't bubble to routes, + // so we have to manually bubble here + this.send('refreshModel'); + }, + + hasChanges(hasChanges) { + this.send('hasDataChanges', hasChanges); + }, + }, +}); diff --git a/ui/app/controllers/vault/cluster/secrets/backend/list-root.js b/ui/app/controllers/vault/cluster/secrets/backend/list-root.js new file mode 100644 index 000000000..ea1fedd0a --- /dev/null +++ b/ui/app/controllers/vault/cluster/secrets/backend/list-root.js @@ -0,0 +1 @@ +export { default } from './list'; diff --git a/ui/app/controllers/vault/cluster/secrets/backend/list.js b/ui/app/controllers/vault/cluster/secrets/backend/list.js new file mode 100644 index 000000000..a113f5cb1 --- /dev/null +++ b/ui/app/controllers/vault/cluster/secrets/backend/list.js @@ -0,0 +1,73 @@ +import Ember from 'ember'; +import utils from 'vault/lib/key-utils'; +import BackendCrumbMixin from 'vault/mixins/backend-crumb'; + +export default Ember.Controller.extend(BackendCrumbMixin, { + flashMessages: Ember.inject.service(), + queryParams: ['page', 'pageFilter', 'tab'], + + tab: '', + page: 1, + pageFilter: null, + filterFocused: false, + + // set via the route `loading` action + isLoading: false, + + filterMatchesKey: Ember.computed('filter', 'model', 'model.[]', function() { + var filter = this.get('filter'); + var content = this.get('model'); + return !!(content.length && content.findBy('id', filter)); + }), + + firstPartialMatch: Ember.computed('filter', 'model', 'model.[]', 'filterMatchesKey', function() { + var filter = this.get('filter'); + var content = this.get('model'); + var filterMatchesKey = this.get('filterMatchesKey'); + var re = new RegExp('^' + filter); + return filterMatchesKey + ? null + : content.find(function(key) { + return re.test(key.get('id')); + }); + }), + + filterIsFolder: Ember.computed('filter', function() { + return !!utils.keyIsFolder(this.get('filter')); + }), + + actions: { + setFilter(val) { + this.set('filter', val); + }, + + setFilterFocus(bool) { + this.set('filterFocused', bool); + }, + + chooseAction(action) { + this.set('selectedAction', action); + }, + + toggleZeroAddress(item, backend) { + item.toggleProperty('zeroAddress'); + this.set('loading-' + item.id, true); + backend + .saveZeroAddressConfig() + .catch(e => { + item.set('zeroAddress', false); + this.get('flashMessages').danger(e.message); + }) + .finally(() => { + this.set('loading-' + item.id, false); + }); + }, + + delete(item) { + const name = item.id; + item.destroyRecord().then(() => { + this.get('flashMessages').success(`${name} was successfully deleted.`); + }); + }, + }, +}); diff --git a/ui/app/controllers/vault/cluster/secrets/backend/show-root.js b/ui/app/controllers/vault/cluster/secrets/backend/show-root.js new file mode 100644 index 000000000..98bd0c1a0 --- /dev/null +++ b/ui/app/controllers/vault/cluster/secrets/backend/show-root.js @@ -0,0 +1 @@ +export { default } from './show'; diff --git a/ui/app/controllers/vault/cluster/secrets/backend/show.js b/ui/app/controllers/vault/cluster/secrets/backend/show.js new file mode 100644 index 000000000..d72f6bdc8 --- /dev/null +++ b/ui/app/controllers/vault/cluster/secrets/backend/show.js @@ -0,0 +1,21 @@ +import Ember from 'ember'; +import BackendCrumbMixin from 'vault/mixins/backend-crumb'; + +export default Ember.Controller.extend(BackendCrumbMixin, { + queryParams: ['tab'], + tab: '', + reset() { + this.set('tab', ''); + }, + actions: { + refresh: function() { + // closure actions don't bubble to routes, + // so we have to manually bubble here + this.send('refreshModel'); + }, + + hasChanges(hasChanges) { + this.send('hasDataChanges', hasChanges); + }, + }, +}); diff --git a/ui/app/controllers/vault/cluster/secrets/backend/sign-root.js b/ui/app/controllers/vault/cluster/secrets/backend/sign-root.js new file mode 100644 index 000000000..d034c1595 --- /dev/null +++ b/ui/app/controllers/vault/cluster/secrets/backend/sign-root.js @@ -0,0 +1 @@ +export { default } from './sign'; diff --git a/ui/app/controllers/vault/cluster/secrets/backend/sign.js b/ui/app/controllers/vault/cluster/secrets/backend/sign.js new file mode 100644 index 000000000..5e5e3bd47 --- /dev/null +++ b/ui/app/controllers/vault/cluster/secrets/backend/sign.js @@ -0,0 +1,36 @@ +import Ember from 'ember'; +const { get, set } = Ember; + +export default Ember.Controller.extend({ + store: Ember.inject.service(), + loading: false, + emptyData: '{\n}', + actions: { + sign() { + this.set('loading', true); + this.model.save().finally(() => { + this.set('loading', false); + }); + }, + + codemirrorUpdated(attr, val, codemirror) { + codemirror.performLint(); + const hasErrors = codemirror.state.lint.marked.length > 0; + + if (!hasErrors) { + set(this.get('model'), attr, JSON.parse(val)); + } + }, + + newModel() { + const model = this.get('model'); + const roleModel = model.get('role'); + model.unloadRecord(); + const newModel = this.get('store').createRecord('ssh-sign', { + role: roleModel, + id: `${get(roleModel, 'backend')}-${get(roleModel, 'name')}`, + }); + this.set('model', newModel); + }, + }, +}); diff --git a/ui/app/controllers/vault/cluster/secrets/backends.js b/ui/app/controllers/vault/cluster/secrets/backends.js new file mode 100644 index 000000000..31195d10f --- /dev/null +++ b/ui/app/controllers/vault/cluster/secrets/backends.js @@ -0,0 +1,27 @@ +import Ember from 'ember'; +import { supportedSecretBackends } from 'vault/helpers/supported-secret-backends'; +const { computed, Controller } = Ember; +const LINKED_BACKENDS = supportedSecretBackends(); + +export default Controller.extend({ + displayableBackends: computed.filterBy('model', 'shouldIncludeInList'), + + supportedBackends: computed('displayableBackends', 'displayableBackends.[]', function() { + return (this.get('displayableBackends') || []) + .filter(backend => LINKED_BACKENDS.includes(backend.get('type'))) + .sortBy('id'); + }), + + unsupportedBackends: computed( + 'displayableBackends', + 'displayableBackends.[]', + 'supportedBackends', + 'supportedBackends.[]', + function() { + return (this.get('displayableBackends') || []) + .slice() + .removeObjects(this.get('supportedBackends')) + .sortBy('id'); + } + ), +}); diff --git a/ui/app/controllers/vault/cluster/settings/auth/enable.js b/ui/app/controllers/vault/cluster/settings/auth/enable.js new file mode 100644 index 000000000..945a3bf89 --- /dev/null +++ b/ui/app/controllers/vault/cluster/settings/auth/enable.js @@ -0,0 +1,12 @@ +import Ember from 'ember'; + +export default Ember.Controller.extend({ + actions: { + onMountSuccess: function() { + return this.transitionToRoute('vault.cluster.access.methods'); + }, + onConfigError: function(modelId) { + return this.transitionToRoute('vault.cluster.settings.auth.configure', modelId); + }, + }, +}); diff --git a/ui/app/controllers/vault/cluster/settings/configure-secret-backend.js b/ui/app/controllers/vault/cluster/settings/configure-secret-backend.js new file mode 100644 index 000000000..a2401f0ad --- /dev/null +++ b/ui/app/controllers/vault/cluster/settings/configure-secret-backend.js @@ -0,0 +1,67 @@ +import Ember from 'ember'; + +const CONFIG_ATTRS = { + // ssh + configured: false, + + // aws root config + iamEndpoint: null, + stsEndpoint: null, + accessKey: null, + secretKey: null, + region: '', +}; + +export default Ember.Controller.extend(CONFIG_ATTRS, { + queryParams: ['tab'], + tab: '', + flashMessages: Ember.inject.service(), + loading: false, + reset() { + this.get('model').rollbackAttributes(); + this.setProperties(CONFIG_ATTRS); + }, + actions: { + saveConfig(options = { delete: false }) { + const isDelete = options.delete; + if (this.get('model.type') === 'ssh') { + this.set('loading', true); + this.get('model').saveCA({ isDelete }).then(() => { + this.set('loading', false); + this.send('refreshRoute'); + this.set('configured', !isDelete); + if (isDelete) { + this.get('flashMessages').success('SSH Certificate Authority Configuration deleted!'); + } else { + this.get('flashMessages').success('SSH Certificate Authority Configuration saved!'); + } + }); + } + }, + + save(method, data) { + this.set('loading', true); + const hasData = Object.keys(data).some(key => { + return Ember.isPresent(data[key]); + }); + if (!hasData) { + return; + } + this.get('model') + .save({ + adapterOptions: { + adapterMethod: method, + data, + }, + }) + .then(() => { + this.get('model').send('pushedData'); + this.reset(); + this.get('flashMessages').success('The backend configuration saved successfully!'); + }) + .finally(() => { + this.set('loading', false); + }); + }, + }, +}); diff --git a/ui/app/controllers/vault/cluster/settings/mount-secret-backend.js b/ui/app/controllers/vault/cluster/settings/mount-secret-backend.js new file mode 100644 index 000000000..f23db482a --- /dev/null +++ b/ui/app/controllers/vault/cluster/settings/mount-secret-backend.js @@ -0,0 +1,143 @@ +import Ember from 'ember'; +import { supportedSecretBackends } from 'vault/helpers/supported-secret-backends'; + +const SUPPORTED_BACKENDS = supportedSecretBackends(); + +const { computed } = Ember; + +export default Ember.Controller.extend({ + mountTypes: [ + { label: 'AWS', value: 'aws' }, + { label: 'Cassandra', value: 'cassandra' }, + { label: 'Consul', value: 'consul' }, + { label: 'Databases', value: 'database' }, + { label: 'KV', value: 'kv' }, + { label: 'MongoDB', value: 'mongodb' }, + { label: 'MS SQL', value: 'mssql', deprecated: true }, + { label: 'MySQL', value: 'mysql', deprecated: true }, + { label: 'Nomad', value: 'nomad' }, + { label: 'PKI', value: 'pki' }, + { label: 'PostgreSQL', value: 'postgresql', deprecated: true }, + { label: 'RabbitMQ', value: 'rabbitmq' }, + { label: 'SSH', value: 'ssh' }, + { label: 'Transit', value: 'transit' }, + { label: 'TOTP', value: 'totp' }, + ], + + selectedType: null, + selectedPath: null, + description: null, + default_lease_ttl: null, + max_lease_ttl: null, + force_no_cache: null, + showConfig: false, + local: false, + sealWrap: false, + versioned: true, + + selection: computed('selectedType', function() { + return this.get('mountTypes').findBy('value', this.get('selectedType')); + }), + + flashMessages: Ember.inject.service(), + + reset() { + const defaultBackend = this.get('mountTypes.firstObject.value'); + this.setProperties({ + selectedPath: defaultBackend, + selectedType: defaultBackend, + description: null, + default_lease_ttl: null, + max_lease_ttl: null, + force_no_cache: null, + local: false, + showConfig: false, + sealWrap: false, + versioned: true, + }); + }, + + init() { + this._super(...arguments); + this.reset(); + }, + + actions: { + onTypeChange(val) { + const { selectedPath, selectedType } = this.getProperties('selectedPath', 'selectedType'); + this.set('selectedType', val); + if (selectedPath === selectedType) { + this.set('selectedPath', val); + } + }, + + toggleShowConfig() { + this.toggleProperty('showConfig'); + }, + + mountBackend() { + const { + selectedPath: path, + selectedType: type, + description, + default_lease_ttl, + force_no_cache, + local, + max_lease_ttl, + sealWrap, + versioned, + } = this.getProperties( + 'selectedPath', + 'selectedType', + 'description', + 'default_lease_ttl', + 'force_no_cache', + 'local', + 'max_lease_ttl', + 'sealWrap', + 'versioned' + ); + const currentModel = this.get('model'); + if (currentModel && currentModel.rollbackAttributes) { + currentModel.rollbackAttributes(); + } + let attrs = { + path, + type, + description, + local, + sealWrap, + }; + + if (this.get('showConfig')) { + attrs.config = { + default_lease_ttl, + max_lease_ttl, + force_no_cache, + }; + } + + if (type === 'kv' && versioned) { + attrs.options = { + versioned: 'true', + }; + } + + const model = this.store.createRecord('secret-engine', attrs); + + this.set('model', model); + model.save().then(() => { + this.reset(); + let transition; + if (SUPPORTED_BACKENDS.includes(type)) { + transition = this.transitionToRoute('vault.cluster.secrets.backend.index', path); + } else { + transition = this.transitionToRoute('vault.cluster.secrets.backends'); + } + transition.followRedirects().then(() => { + this.get('flashMessages').success(`Successfully mounted '${type}' at '${path}'!`); + }); + }); + }, + }, +}); diff --git a/ui/app/controllers/vault/cluster/settings/seal.js b/ui/app/controllers/vault/cluster/settings/seal.js new file mode 100644 index 000000000..53017f39e --- /dev/null +++ b/ui/app/controllers/vault/cluster/settings/seal.js @@ -0,0 +1,15 @@ +import Ember from 'ember'; + +export default Ember.Controller.extend({ + auth: Ember.inject.service(), + + actions: { + seal() { + return this.model.cluster.store.adapterFor('cluster').seal().then(() => { + this.model.cluster.get('leaderNode').set('sealed', true); + this.get('auth').deleteCurrentToken(); + return this.transitionToRoute('vault.cluster'); + }); + }, + }, +}); diff --git a/ui/app/controllers/vault/cluster/unseal.js b/ui/app/controllers/vault/cluster/unseal.js new file mode 100644 index 000000000..d20b38125 --- /dev/null +++ b/ui/app/controllers/vault/cluster/unseal.js @@ -0,0 +1,14 @@ +import Ember from 'ember'; + +export default Ember.Controller.extend({ + actions: { + transitionToCluster() { + return this.get('model').reload().then(() => { + return this.transitionToRoute('vault.cluster', this.get('model.name')); + }); + }, + isUnsealed(data) { + return data.sealed === false; + }, + }, +}); diff --git a/ui/app/helpers/.gitkeep b/ui/app/helpers/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/ui/app/helpers/add.js b/ui/app/helpers/add.js new file mode 100644 index 000000000..0f2a78a20 --- /dev/null +++ b/ui/app/helpers/add.js @@ -0,0 +1,7 @@ +import Ember from 'ember'; + +export function add(params) { + return params.reduce((sum, param) => parseInt(param, 0) + sum, 0); +} + +export default Ember.Helper.helper(add); diff --git a/ui/app/helpers/aws-regions.js b/ui/app/helpers/aws-regions.js new file mode 100644 index 000000000..5901e157d --- /dev/null +++ b/ui/app/helpers/aws-regions.js @@ -0,0 +1,25 @@ +import Ember from 'ember'; + +//list from http://docs.aws.amazon.com/general/latest/gr/rande.html#sts_region +const REGIONS = [ + 'us-east-1', + 'us-east-2', + 'us-west-1', + 'us-west-2', + 'ca-central-1', + 'ap-south-1', + 'ap-northeast-1', + 'ap-northeast-2', + 'ap-southeast-1', + 'ap-southeast-2', + 'eu-central-1', + 'eu-west-1', + 'eu-west-2', + 'sa-east-1', +]; + +export function regions() { + return REGIONS.slice(0); +} + +export default Ember.Helper.helper(regions); diff --git a/ui/app/helpers/coerce-eq.js b/ui/app/helpers/coerce-eq.js new file mode 100644 index 000000000..c53ef23a7 --- /dev/null +++ b/ui/app/helpers/coerce-eq.js @@ -0,0 +1,8 @@ +/*jshint eqeqeq: false */ +import Ember from 'ember'; + +export function coerceEq(params) { + return params[0] == params[1]; +} + +export default Ember.Helper.helper(coerceEq); diff --git a/ui/app/helpers/has-feature.js b/ui/app/helpers/has-feature.js new file mode 100644 index 000000000..f260b7fd7 --- /dev/null +++ b/ui/app/helpers/has-feature.js @@ -0,0 +1,32 @@ +import Ember from 'ember'; +const { Helper, inject, observer } = Ember; + +const FEATURES = [ + 'HSM', + 'Performance Replication', + 'DR Replication', + 'MFA', + 'Sentinel', + 'AWS KMS Autounseal', + 'GCP CKMS Autounseal', + 'Seal Wrapping', + 'Control Groups', +]; + +export function hasFeature(featureName, features) { + if (!FEATURES.includes(featureName)) { + Ember.assert(`${featureName} is not one of the available values for Vault Enterprise features.`, false); + return false; + } + return features ? features.includes(featureName) : false; +} + +export default Helper.extend({ + version: inject.service(), + onFeaturesChange: observer('version.features.[]', function() { + this.recompute(); + }), + compute([featureName]) { + return hasFeature(featureName, this.get('version.features')); + }, +}); diff --git a/ui/app/helpers/includes.js b/ui/app/helpers/includes.js new file mode 100644 index 000000000..7e460fc24 --- /dev/null +++ b/ui/app/helpers/includes.js @@ -0,0 +1,7 @@ +import Ember from 'ember'; + +export function includes([haystack, needle]) { + return haystack.includes(needle); +} + +export default Ember.Helper.helper(includes); diff --git a/ui/app/helpers/is-active-route.js b/ui/app/helpers/is-active-route.js new file mode 100644 index 000000000..1f5a0709a --- /dev/null +++ b/ui/app/helpers/is-active-route.js @@ -0,0 +1,34 @@ +import Ember from 'ember'; + +const { Helper, inject, observer } = Ember; + +const exact = (a, b) => a === b; +const startsWith = (a, b) => a.indexOf(b) === 0; + +export default Helper.extend({ + routing: inject.service('-routing'), + + onRouteChange: observer('routing.router.currentURL', 'routing.router.currentRouteName', function() { + this.recompute(); + }), + + compute([routeName, model], { isExact }) { + const router = this.get('routing.router'); + const currentRoute = router.get('currentRouteName'); + let currentURL = router.get('currentURL'); + // if we have any query params we want to discard them + currentURL = currentURL.split('?')[0]; + const comparator = isExact ? exact : startsWith; + if (!currentRoute) { + return false; + } + if (Ember.isArray(routeName)) { + return routeName.some(name => comparator(currentRoute, name)); + } else if (model) { + // slice off the rootURL from the generated route + return comparator(currentURL, router.generate(routeName, model).slice(router.rootURL.length - 1)); + } else { + return comparator(currentRoute, routeName); + } + }, +}); diff --git a/ui/app/helpers/is-version.js b/ui/app/helpers/is-version.js new file mode 100644 index 000000000..85bd65242 --- /dev/null +++ b/ui/app/helpers/is-version.js @@ -0,0 +1,16 @@ +import Ember from 'ember'; +const { Helper, inject, observer } = Ember; + +export default Helper.extend({ + version: inject.service(), + onFeaturesChange: observer('version.version', function() { + this.recompute(); + }), + compute([sku]) { + if (sku !== 'OSS' && sku !== 'Enterprise') { + Ember.assert(`${sku} is not one of the available values for Vault versions.`, false); + return false; + } + return this.get(`version.is${sku}`); + }, +}); diff --git a/ui/app/helpers/jsonify.js b/ui/app/helpers/jsonify.js new file mode 100644 index 000000000..259c8d470 --- /dev/null +++ b/ui/app/helpers/jsonify.js @@ -0,0 +1,7 @@ +import Ember from 'ember'; + +export function jsonify([target]) { + return JSON.parse(target); +} + +export default Ember.Helper.helper(jsonify); diff --git a/ui/app/helpers/message-types.js b/ui/app/helpers/message-types.js new file mode 100644 index 000000000..a42e8f2f1 --- /dev/null +++ b/ui/app/helpers/message-types.js @@ -0,0 +1,34 @@ +import Ember from 'ember'; + +const MESSAGE_TYPES = { + info: { + class: 'is-info', + glyphClass: 'has-text-info', + glyph: 'information-circled', + text: 'Info', + }, + success: { + class: 'is-success', + glyphClass: 'has-text-success', + glyph: 'checkmark-circled', + text: 'Success', + }, + danger: { + class: 'is-danger', + glyphClass: 'has-text-danger', + glyph: 'close-circled', + text: 'Error', + }, + warning: { + class: 'is-highlight', + glyphClass: 'has-text-highlight', + glyph: 'alert-circled', + text: 'Attention', + }, +}; + +export function messageTypes([type]) { + return MESSAGE_TYPES[type]; +} + +export default Ember.Helper.helper(messageTypes); diff --git a/ui/app/helpers/mountable-auth-methods.js b/ui/app/helpers/mountable-auth-methods.js new file mode 100644 index 000000000..42433132a --- /dev/null +++ b/ui/app/helpers/mountable-auth-methods.js @@ -0,0 +1,60 @@ +import Ember from 'ember'; + +const MOUNTABLE_AUTH_METHODS = [ + { + displayName: 'AppRole', + value: 'approle', + type: 'approle', + }, + { + displayName: 'AWS', + value: 'aws', + type: 'aws', + }, + { + displayName: 'Google Cloud', + value: 'gcp', + type: 'gcp', + }, + { + displayName: 'Kubernetes', + value: 'kubernetes', + type: 'kubernetes', + }, + { + displayName: 'GitHub', + value: 'github', + type: 'github', + }, + { + displayName: 'LDAP', + value: 'ldap', + type: 'ldap', + }, + { + displayName: 'Okta', + value: 'okta', + type: 'okta', + }, + { + displayName: 'RADIUS', + value: 'radius', + type: 'radius', + }, + { + displayName: 'TLS Certificates', + value: 'cert', + type: 'cert', + }, + { + displayName: 'Username & Password', + value: 'userpass', + type: 'userpass', + }, +]; + +export function methods() { + return MOUNTABLE_AUTH_METHODS; +} + +export default Ember.Helper.helper(methods); diff --git a/ui/app/helpers/nav-to-route.js b/ui/app/helpers/nav-to-route.js new file mode 100644 index 000000000..93d348511 --- /dev/null +++ b/ui/app/helpers/nav-to-route.js @@ -0,0 +1,15 @@ +import Ember from 'ember'; + +const { Helper, inject } = Ember; + +export default Helper.extend({ + routing: inject.service('-routing'), + + compute([routeName, ...models], { replace = false }) { + return () => { + const router = this.get('routing.router'); + const method = replace ? router.replaceWith : router.transitionTo; + return method.call(router, routeName, ...models); + }; + }, +}); diff --git a/ui/app/helpers/options-for-backend.js b/ui/app/helpers/options-for-backend.js new file mode 100644 index 000000000..da626cb57 --- /dev/null +++ b/ui/app/helpers/options-for-backend.js @@ -0,0 +1,84 @@ +import Ember from 'ember'; + +const DEFAULT_DISPLAY = { + searchPlaceholder: 'Filter secrets', + item: 'secret', + create: 'Create secret', + navigateTree: true, + editComponent: 'secret-edit', + listItemPartial: 'partials/secret-list/item', +}; +const SECRET_BACKENDS = { + aws: { + displayName: 'AWS', + searchPlaceholder: 'Filter roles', + item: 'role', + create: 'Create role', + navigateTree: false, + editComponent: 'role-aws-edit', + listItemPartial: 'partials/secret-list/aws-role-item', + }, + pki: { + displayName: 'PKI', + navigateTree: false, + listItemPartial: 'partials/secret-list/pki-role-item', + tabs: [ + { + name: 'roles', + label: 'Roles', + searchPlaceholder: 'Filter roles', + item: 'role', + create: 'Create role', + editComponent: 'role-pki-edit', + }, + { + name: 'certs', + modelPrefix: 'cert/', + label: 'Certificates', + searchPlaceholder: 'Filter certificates', + item: 'certificates', + create: 'Create role', + tab: 'certs', + listItemPartial: 'partials/secret-list/pki-cert-item', + editComponent: 'pki-cert-show', + }, + ], + }, + ssh: { + displayName: 'SSH', + searchPlaceholder: 'Filter roles', + item: 'role', + create: 'Create role', + navigateTree: false, + editComponent: 'role-ssh-edit', + listItemPartial: 'partials/secret-list/ssh-role-item', + }, + transit: { + searchPlaceholder: 'Filter keys', + item: 'key', + create: 'Create encryption key', + navigateTree: false, + editComponent: 'transit-edit', + listItemPartial: 'partials/secret-list/item', + }, +}; + +export function optionsForBackend([backend, tab]) { + const selected = SECRET_BACKENDS[backend]; + let backendOptions; + + if (selected && selected.tabs) { + let tabData = + selected.tabs.findBy('name', tab) || selected.tabs.findBy('modelPrefix', tab) || selected.tabs[0]; + backendOptions = Ember.assign({}, selected, tabData); + } else if (selected) { + backendOptions = selected; + } else { + backendOptions = Ember.assign({}, DEFAULT_DISPLAY, { + displayName: backend === 'kv' ? 'KV' : Ember.String.capitalize(backend), + }); + } + return backendOptions; +} + +export default Ember.Helper.helper(optionsForBackend); diff --git a/ui/app/helpers/reduce-to-array.js b/ui/app/helpers/reduce-to-array.js new file mode 100644 index 000000000..bca0c1978 --- /dev/null +++ b/ui/app/helpers/reduce-to-array.js @@ -0,0 +1,16 @@ +import Ember from 'ember'; + +export function reduceToArray(params) { + return params.reduce(function(result, param) { + if (Ember.isNone(param)) { + return result; + } + if (Ember.typeOf(param) === 'array') { + return result.concat(param); + } else { + return result.concat([param]); + } + }, []); +} + +export default Ember.Helper.helper(reduceToArray); diff --git a/ui/app/helpers/replication-action-for-mode.js b/ui/app/helpers/replication-action-for-mode.js new file mode 100644 index 000000000..8abd86c7e --- /dev/null +++ b/ui/app/helpers/replication-action-for-mode.js @@ -0,0 +1,19 @@ +import Ember from 'ember'; +const ACTIONS = { + performance: { + primary: ['disable', 'demote', 'recover', 'reindex'], + secondary: ['disable', 'promote', 'update-primary', 'recover', 'reindex'], + bootstrapping: ['disable', 'recover', 'reindex'], + }, + dr: { + primary: ['disable', 'recover', 'reindex', 'demote'], + secondary: ['promote'], + bootstrapping: ['disable', 'recover', 'reindex'], + }, +}; + +export function replicationActionForMode([replicationMode, clusterMode] /*, hash*/) { + return Ember.get(ACTIONS, `${replicationMode}.${clusterMode}`); +} + +export default Ember.Helper.helper(replicationActionForMode); diff --git a/ui/app/helpers/set-flash-message.js b/ui/app/helpers/set-flash-message.js new file mode 100644 index 000000000..be5126065 --- /dev/null +++ b/ui/app/helpers/set-flash-message.js @@ -0,0 +1,13 @@ +import Ember from 'ember'; + +const { Helper, inject } = Ember; + +export default Helper.extend({ + flashMessages: inject.service(), + + compute([message, type]) { + return () => { + this.get('flashMessages')[type || 'success'](message); + }; + }, +}); diff --git a/ui/app/helpers/sha2-digest-sizes.js b/ui/app/helpers/sha2-digest-sizes.js new file mode 100644 index 000000000..3eb1624ff --- /dev/null +++ b/ui/app/helpers/sha2-digest-sizes.js @@ -0,0 +1,9 @@ +import Ember from 'ember'; + +const SHA2_DIGEST_SIZES = ['sha2-224', 'sha2-256', 'sha2-384', 'sha2-512']; + +export function sha2DigestSizes() { + return SHA2_DIGEST_SIZES; +} + +export default Ember.Helper.helper(sha2DigestSizes); diff --git a/ui/app/helpers/stringify.js b/ui/app/helpers/stringify.js new file mode 100644 index 000000000..a49427025 --- /dev/null +++ b/ui/app/helpers/stringify.js @@ -0,0 +1,10 @@ +import Ember from 'ember'; + +export function stringify([target], { skipFormat }) { + if (skipFormat) { + return JSON.stringify(target); + } + return JSON.stringify(target, null, 2); +} + +export default Ember.Helper.helper(stringify); diff --git a/ui/app/helpers/supported-auth-backends.js b/ui/app/helpers/supported-auth-backends.js new file mode 100644 index 000000000..2db68af54 --- /dev/null +++ b/ui/app/helpers/supported-auth-backends.js @@ -0,0 +1,40 @@ +import Ember from 'ember'; + +const SUPPORTED_AUTH_BACKENDS = [ + { + type: 'token', + description: 'Token authentication.', + tokenPath: 'id', + displayNamePath: 'display_name', + }, + { + type: 'userpass', + description: 'A simple username and password backend.', + tokenPath: 'client_token', + displayNamePath: 'metadata.username', + }, + { + type: 'LDAP', + description: 'LDAP authentication.', + tokenPath: 'client_token', + displayNamePath: 'metadata.username', + }, + { + type: 'Okta', + description: 'Authenticate with your Okta username and password.', + tokenPath: 'client_token', + displayNamePath: 'metadata.username', + }, + { + type: 'GitHub', + description: 'GitHub authentication.', + tokenPath: 'client_token', + displayNamePath: ['metadata.org', 'metadata.username'], + }, +]; + +export function supportedAuthBackends() { + return SUPPORTED_AUTH_BACKENDS; +} + +export default Ember.Helper.helper(supportedAuthBackends); diff --git a/ui/app/helpers/supported-secret-backends.js b/ui/app/helpers/supported-secret-backends.js new file mode 100644 index 000000000..b2f8dc78e --- /dev/null +++ b/ui/app/helpers/supported-secret-backends.js @@ -0,0 +1,9 @@ +import Ember from 'ember'; + +const SUPPORTED_SECRET_BACKENDS = ['aws', 'cubbyhole', 'generic', 'kv', 'pki', 'ssh', 'transit']; + +export function supportedSecretBackends() { + return SUPPORTED_SECRET_BACKENDS; +} + +export default Ember.Helper.helper(supportedSecretBackends); diff --git a/ui/app/helpers/tabs-for-auth-section.js b/ui/app/helpers/tabs-for-auth-section.js new file mode 100644 index 000000000..ca83443f9 --- /dev/null +++ b/ui/app/helpers/tabs-for-auth-section.js @@ -0,0 +1,61 @@ +import Ember from 'ember'; + +const TABS_FOR_SETTINGS = { + aws: [ + { + label: 'Client', + routeParams: ['vault.cluster.settings.auth.configure.section', 'client'], + }, + { + label: 'Identity Whitelist Tidy', + routeParams: ['vault.cluster.settings.auth.configure.section', 'identity-whitelist'], + }, + { + label: 'Role Tag Blacklist Tidy', + routeParams: ['vault.cluster.settings.auth.configure.section', 'roletag-blacklist'], + }, + ], + github: [ + { + label: 'Configuration', + routeParams: ['vault.cluster.settings.auth.configure.section', 'configuration'], + }, + ], + gcp: [ + { + label: 'Configuration', + routeParams: ['vault.cluster.settings.auth.configure.section', 'configuration'], + }, + ], + kubernetes: [ + { + label: 'Configuration', + routeParams: ['vault.cluster.settings.auth.configure.section', 'configuration'], + }, + ], +}; + +const TABS_FOR_SHOW = {}; + +export function tabsForAuthSection([methodType, sectionType = 'authSettings']) { + let tabs; + + if (sectionType === 'authSettings') { + tabs = (TABS_FOR_SETTINGS[methodType] || []).slice(); + tabs.push({ + label: 'Method Options', + routeParams: ['vault.cluster.settings.auth.configure.section', 'options'], + }); + return tabs; + } + + tabs = (TABS_FOR_SHOW[methodType] || []).slice(); + tabs.push({ + label: 'Configuration', + routeParams: ['vault.cluster.access.method.section', 'configuration'], + }); + + return tabs; +} + +export default Ember.Helper.helper(tabsForAuthSection); diff --git a/ui/app/helpers/tabs-for-identity-show.js b/ui/app/helpers/tabs-for-identity-show.js new file mode 100644 index 000000000..a1c808ca1 --- /dev/null +++ b/ui/app/helpers/tabs-for-identity-show.js @@ -0,0 +1,21 @@ +import Ember from 'ember'; + +export const TABS = { + entity: ['details', 'aliases', 'policies', 'groups', 'metadata'], + 'entity-alias': ['details', 'metadata'], + //group will be used in the model hook of the route + group: ['details', 'aliases', 'policies', 'members', 'metadata'], + 'group-internal': ['details', 'policies', 'members', 'metadata'], + 'group-external': ['details', 'aliases', 'policies', 'members', 'metadata'], + 'group-alias': ['details'], +}; + +export function tabsForIdentityShow([modelType, groupType]) { + let key = modelType; + if (groupType) { + key = `${key}-${groupType}`; + } + return TABS[key]; +} + +export default Ember.Helper.helper(tabsForIdentityShow); diff --git a/ui/app/helpers/to-label.js b/ui/app/helpers/to-label.js new file mode 100644 index 000000000..a1691a795 --- /dev/null +++ b/ui/app/helpers/to-label.js @@ -0,0 +1,10 @@ +import Ember from 'ember'; +import { capitalize } from 'vault/helpers/capitalize'; +import { humanize } from 'vault/helpers/humanize'; +import { dasherize } from 'vault/helpers/dasherize'; + +export function toLabel(val) { + return capitalize([humanize([dasherize(val)])]); +} + +export default Ember.Helper.helper(toLabel); diff --git a/ui/app/helpers/tools-actions.js b/ui/app/helpers/tools-actions.js new file mode 100644 index 000000000..83b5bae4a --- /dev/null +++ b/ui/app/helpers/tools-actions.js @@ -0,0 +1,9 @@ +import Ember from 'ember'; + +const TOOLS_ACTIONS = ['wrap', 'lookup', 'unwrap', 'rewrap', 'random', 'hash']; + +export function toolsActions() { + return TOOLS_ACTIONS; +} + +export default Ember.Helper.helper(toolsActions); diff --git a/ui/app/index.html b/ui/app/index.html new file mode 100644 index 000000000..6de475b6b --- /dev/null +++ b/ui/app/index.html @@ -0,0 +1,27 @@ + + + + + + + + + Vault + + + {{content-for "head"}} + + + + + {{content-for "head-footer"}} + + + {{content-for "body"}} + + + + + {{content-for "body-footer"}} + + diff --git a/ui/app/initializers/disable-ember-inspector.js b/ui/app/initializers/disable-ember-inspector.js new file mode 100644 index 000000000..7cb22c3bd --- /dev/null +++ b/ui/app/initializers/disable-ember-inspector.js @@ -0,0 +1,11 @@ +import config from '../config/environment'; + +export default { + name: 'ember-inspect-disable', + initialize: function() { + if (config.environment === 'production') { + // disables ember inspector + window.NO_EMBER_DEBUG = true; + } + }, +}; diff --git a/ui/app/lib/key-utils.js b/ui/app/lib/key-utils.js new file mode 100644 index 000000000..e5c7166b9 --- /dev/null +++ b/ui/app/lib/key-utils.js @@ -0,0 +1,47 @@ +function keyIsFolder(key) { + return key ? !!key.match(/\/$/) : false; +} + +function keyPartsForKey(key) { + if (!key) { + return null; + } + var isFolder = keyIsFolder(key); + var parts = key.split('/'); + if (isFolder) { + parts.pop(); + } + return parts.length > 1 ? parts : null; +} + +function parentKeyForKey(key) { + var parts = keyPartsForKey(key); + if (!parts) { + return null; + } + return parts.slice(0, -1).join('/') + '/'; +} + +function keyWithoutParentKey(key) { + return key ? key.replace(parentKeyForKey(key), '') : null; +} + +function ancestorKeysForKey(key) { + var ancestors = [], + parentKey = parentKeyForKey(key); + + while (parentKey) { + ancestors.unshift(parentKey); + parentKey = parentKeyForKey(parentKey); + } + + return ancestors.length ? ancestors : null; +} + +export default { + keyIsFolder, + keyPartsForKey, + parentKeyForKey, + keyWithoutParentKey, + ancestorKeysForKey, +}; diff --git a/ui/app/lib/keycodes.js b/ui/app/lib/keycodes.js new file mode 100644 index 000000000..872468021 --- /dev/null +++ b/ui/app/lib/keycodes.js @@ -0,0 +1,11 @@ +// a map of keyCode for use in keyboard event handlers +export default { + ENTER: 13, + ESC: 27, + TAB: 9, + LEFT: 37, + UP: 38, + RIGHT: 39, + DOWN: 40, + T: 116, +}; diff --git a/ui/app/lib/kv-object.js b/ui/app/lib/kv-object.js new file mode 100644 index 000000000..4ac1fda47 --- /dev/null +++ b/ui/app/lib/kv-object.js @@ -0,0 +1,48 @@ +import Ember from 'ember'; + +export default Ember.ArrayProxy.extend({ + fromJSON(json) { + const contents = Object.keys(json || []).map(key => { + let obj = { + name: key, + value: json[key], + }; + Ember.guidFor(obj); + return obj; + }); + this.setObjects( + contents.sort((a, b) => { + if (a.name === '') { + return 1; + } + if (b.name === '') { + return -1; + } + return a.name.localeCompare(b.name); + }) + ); + return this; + }, + + fromJSONString(jsonString) { + return this.fromJSON(JSON.parse(jsonString)); + }, + + toJSON(includeBlanks = false) { + return this.reduce((obj, item) => { + if (!includeBlanks && item.value === '' && item.name === '') { + return obj; + } + obj[item.name || ''] = item.value || ''; + return obj; + }, {}); + }, + + toJSONString(includeBlanks) { + return JSON.stringify(this.toJSON(includeBlanks), null, 2); + }, + + isAdvanced() { + return this.any(item => typeof item.value !== 'string'); + }, +}); diff --git a/ui/app/lib/local-storage.js b/ui/app/lib/local-storage.js new file mode 100644 index 000000000..86556835c --- /dev/null +++ b/ui/app/lib/local-storage.js @@ -0,0 +1,18 @@ +export default { + getItem(key) { + var item = window.localStorage.getItem(key); + return item && JSON.parse(item); + }, + + setItem(key, val) { + window.localStorage.setItem(key, JSON.stringify(val)); + }, + + removeItem(key) { + return window.localStorage.removeItem(key); + }, + + keys() { + return Object.keys(window.localStorage); + }, +}; diff --git a/ui/app/lib/memory-storage.js b/ui/app/lib/memory-storage.js new file mode 100644 index 000000000..b702afd31 --- /dev/null +++ b/ui/app/lib/memory-storage.js @@ -0,0 +1,20 @@ +let cache = {}; + +export default { + getItem(key) { + var item = cache[key]; + return item && JSON.parse(item); + }, + + setItem(key, val) { + cache[key] = JSON.stringify(val); + }, + + removeItem(key) { + delete cache[key]; + }, + + keys() { + return Object.keys(cache); + }, +}; diff --git a/ui/app/lib/token-storage.js b/ui/app/lib/token-storage.js new file mode 100644 index 000000000..69ee4deb7 --- /dev/null +++ b/ui/app/lib/token-storage.js @@ -0,0 +1,16 @@ +import localStorageWrapper from './local-storage'; +import memoryStorage from './memory-storage'; + +export default function(type) { + if (type === 'memory') { + return memoryStorage; + } + let storage; + try { + window.localStorage.getItem('test'); + storage = localStorageWrapper; + } catch (e) { + storage = memoryStorage; + } + return storage; +} diff --git a/ui/app/mixins/backend-crumb.js b/ui/app/mixins/backend-crumb.js new file mode 100644 index 000000000..8409d60f2 --- /dev/null +++ b/ui/app/mixins/backend-crumb.js @@ -0,0 +1,18 @@ +import Ember from 'ember'; + +export default Ember.Mixin.create({ + backendCrumb: Ember.computed('backend', function() { + const backend = this.get('backend'); + + if (backend === undefined) { + throw new Error('backend-crumb mixin requires backend to be set'); + } + + return { + label: backend, + text: backend, + path: 'vault.cluster.secrets.backend.list-root', + model: backend, + }; + }), +}); diff --git a/ui/app/mixins/cluster-route.js b/ui/app/mixins/cluster-route.js new file mode 100644 index 000000000..e04288ae4 --- /dev/null +++ b/ui/app/mixins/cluster-route.js @@ -0,0 +1,67 @@ +import Ember from 'ember'; + +const { get } = Ember; +const INIT = 'vault.cluster.init'; +const UNSEAL = 'vault.cluster.unseal'; +const AUTH = 'vault.cluster.auth'; +const CLUSTER = 'vault.cluster'; +const DR_REPLICATION_SECONDARY = 'vault.cluster.replication-dr-promote'; + +export { INIT, UNSEAL, AUTH, CLUSTER, DR_REPLICATION_SECONDARY }; + +export default Ember.Mixin.create({ + auth: Ember.inject.service(), + + transitionToTargetRoute() { + const targetRoute = this.targetRouteName(); + if (targetRoute && targetRoute !== this.routeName) { + return this.transitionTo(targetRoute); + } + return Ember.RSVP.resolve(); + }, + + beforeModel() { + return this.transitionToTargetRoute(); + }, + + clusterModel() { + return this.modelFor(CLUSTER); + }, + + authToken() { + return get(this, 'auth.currentToken'); + }, + + hasKeyData() { + return !!get(this.controllerFor(INIT), 'keyData'); + }, + + targetRouteName() { + const cluster = this.clusterModel(); + const isAuthed = this.authToken(); + if (get(cluster, 'needsInit')) { + return INIT; + } + if (this.hasKeyData() && this.routeName !== UNSEAL && this.routeName !== AUTH) { + return INIT; + } + if (get(cluster, 'sealed')) { + return UNSEAL; + } + if (get(cluster, 'dr.isSecondary')) { + return DR_REPLICATION_SECONDARY; + } + if (!isAuthed) { + return AUTH; + } + if ( + (!get(cluster, 'needsInit') && this.routeName === INIT) || + (!get(cluster, 'sealed') && this.routeName === UNSEAL) || + (!get(cluster, 'dr.isSecondary') && this.routeName === DR_REPLICATION_SECONDARY) || + (isAuthed && this.routeName === AUTH) + ) { + return CLUSTER; + } + return null; + }, +}); diff --git a/ui/app/mixins/focus-on-insert.js b/ui/app/mixins/focus-on-insert.js new file mode 100644 index 000000000..97f4871e1 --- /dev/null +++ b/ui/app/mixins/focus-on-insert.js @@ -0,0 +1,28 @@ +import Ember from 'ember'; + +export default Ember.Mixin.create({ + // selector passed to `this.$()` to find the element to focus + // defaults to `'input'` + focusOnInsertSelector: null, + shouldFocus: true, + + // uses Ember.on so that we don't have to worry about calling _super if + // didInsertElement is overridden + focusOnInsert: Ember.on('didInsertElement', function() { + Ember.run.schedule('afterRender', this, 'focusOnInsertFocus'); + }), + + focusOnInsertFocus() { + if (this.get('shouldFocus') === false) { + return; + } + this.forceFocus(); + }, + + forceFocus() { + var $selector = this.$(this.get('focusOnInsertSelector') || 'input').first(); + if (!$selector.is(':focus')) { + $selector.focus(); + } + }, +}); diff --git a/ui/app/mixins/list-controller.js b/ui/app/mixins/list-controller.js new file mode 100644 index 000000000..039d24dc1 --- /dev/null +++ b/ui/app/mixins/list-controller.js @@ -0,0 +1,42 @@ +import Ember from 'ember'; + +export default Ember.Mixin.create({ + queryParams: { + page: 'page', + pageFilter: 'pageFilter', + }, + + page: 1, + pageFilter: null, + filter: null, + + isLoading: false, + + filterMatchesKey: Ember.computed('filter', 'model', 'model.[]', function() { + var filter = this.get('filter'); + var content = this.get('model'); + return !!(content.length && content.findBy('id', filter)); + }), + + firstPartialMatch: Ember.computed('filter', 'model', 'model.[]', 'filterMatchesKey', function() { + var filter = this.get('filter'); + var content = this.get('model'); + var filterMatchesKey = this.get('filterMatchesKey'); + var re = new RegExp('^' + filter); + return filterMatchesKey + ? null + : content.find(function(key) { + return re.test(key.get('id')); + }); + }), + + actions: { + setFilter(val) { + this.set('filter', val); + }, + + setFilterFocus(bool) { + this.set('filterFocused', bool); + }, + }, +}); diff --git a/ui/app/mixins/list-route.js b/ui/app/mixins/list-route.js new file mode 100644 index 000000000..16e5248d4 --- /dev/null +++ b/ui/app/mixins/list-route.js @@ -0,0 +1,12 @@ +import Ember from 'ember'; + +export default Ember.Mixin.create({ + queryParams: { + page: { + refreshModel: true, + }, + pageFilter: { + refreshModel: true, + }, + }, +}); diff --git a/ui/app/mixins/model-boundary-route.js b/ui/app/mixins/model-boundary-route.js new file mode 100644 index 000000000..d38dc8a08 --- /dev/null +++ b/ui/app/mixins/model-boundary-route.js @@ -0,0 +1,58 @@ +// meant for use mixed-in to a Route file +// +// When a route is deactivated, this mixin clears the Ember Data store of +// models of type specified by the required param `modelType`. +// +// example: +// Using this as with a modelType of `datacenter` on the infrastructure +// route will cause all `datacenter` models to get unloaded when the +// infrastructure route is navigated away from. + +import Ember from 'ember'; + +export default Ember.Mixin.create({ + modelType: null, + modelTypes: null, + + verifyProps: Ember.on('init', function() { + var modelType = this.get('modelType'); + var modelTypes = this.get('modelTypes'); + Ember.warn( + 'No `modelType` or `modelTypes` specified for `' + + this.toString() + + '`. Check to make sure you still need to use the `model-boundary-route` mixin.', + Ember.isPresent(modelType) || Ember.isPresent(modelTypes), + { id: 'model-boundary-init' } + ); + + Ember.warn( + 'Expected `model-boundary-route` to be used on an Ember.Route, not `' + this.toString() + '`.', + this instanceof Ember.Route, + { id: 'mode-boundary-is-route' } + ); + }), + + clearModelCache: Ember.on('deactivate', function() { + var modelType = this.get('modelType'); + var modelTypes = this.get('modelTypes'); + + if (!modelType && !modelTypes) { + Ember.warn( + 'Attempted to clear store clear store cache when leaving `' + + this.routeName + + '`, but no `modelType` or `modelTypes` was specified.', + Ember.isPresent(modelType), + { id: 'model-boundary-clear' } + ); + return; + } + if (modelType) { + this.store.unloadAll(modelType); + } + if (modelTypes) { + modelTypes.forEach(type => { + this.store.unloadAll(type); + }); + } + }), +}); diff --git a/ui/app/mixins/policy-edit-controller.js b/ui/app/mixins/policy-edit-controller.js new file mode 100644 index 000000000..69f425f74 --- /dev/null +++ b/ui/app/mixins/policy-edit-controller.js @@ -0,0 +1,40 @@ +import Ember from 'ember'; + +let { inject } = Ember; + +export default Ember.Mixin.create({ + flashMessages: inject.service(), + actions: { + deletePolicy(model) { + let policyType = model.get('policyType'); + let name = model.get('name'); + let flash = this.get('flashMessages'); + model + .destroyRecord() + .then(() => { + flash.success(`${policyType.toUpperCase()} policy "${name}" was successfully deleted.`); + return this.transitionToRoute('vault.cluster.policies', policyType); + }) + .catch(e => { + let errors = e.errors ? e.errors.join('') : e.message; + flash.danger( + `There was an error deleting the ${policyType.toUpperCase()} policy "${name}": ${errors}.` + ); + }); + }, + + savePolicy(model) { + let flash = this.get('flashMessages'); + let policyType = model.get('policyType'); + let name = model.get('name'); + model.save().then(m => { + flash.success(`${policyType.toUpperCase()} policy "${name}" was successfully saved.`); + return this.transitionToRoute('vault.cluster.policy.show', m.get('policyType'), m.get('name')); + }); + }, + + setModelName(model, e) { + model.set('name', e.target.value.toLowerCase()); + }, + }, +}); diff --git a/ui/app/mixins/replication-actions.js b/ui/app/mixins/replication-actions.js new file mode 100644 index 000000000..0fe0466b2 --- /dev/null +++ b/ui/app/mixins/replication-actions.js @@ -0,0 +1,101 @@ +import Ember from 'ember'; +const { inject, computed } = Ember; + +export default Ember.Mixin.create({ + store: inject.service(), + routing: inject.service('-routing'), + router: computed.alias('routing.router'), + submitHandler(action, clusterMode, data, event) { + let replicationMode = (data && data.replicationMode) || this.get('replicationMode'); + if (event && event.preventDefault) { + event.preventDefault(); + } + this.setProperties({ + loading: true, + errors: [], + }); + if (data) { + data = Object.keys(data).reduce((newData, key) => { + var val = data[key]; + if (Ember.isPresent(val)) { + newData[key] = val; + } + return newData; + }, {}); + delete data.replicationMode; + } + + return this.get('store') + .adapterFor('cluster') + .replicationAction(action, replicationMode, clusterMode, data) + .then( + resp => { + return this.submitSuccess(resp, action, clusterMode); + }, + (...args) => this.submitError(...args) + ); + }, + + submitSuccess(resp, action, mode) { + const cluster = this.get('cluster'); + const replicationMode = this.get('selectedReplicationMode') || this.get('replicationMode'); + const store = this.get('store'); + if (!cluster) { + return; + } + + if (resp && resp.wrap_info) { + this.set('token', resp.wrap_info.token); + } + if (action === 'secondary-token') { + this.setProperties({ + loading: false, + primary_api_addr: null, + primary_cluster_addr: null, + }); + return cluster; + } + this.reset(); + if (action === 'enable') { + // do something to show model is pending + cluster.set( + replicationMode, + store.createFragment('replication-attributes', { + mode: 'bootstrapping', + }) + ); + if (mode === 'secondary' && replicationMode === 'performance') { + // if we're enabing a secondary, there could be mount filtering, + // so we should unload all of the backends + store.unloadAll('secret-engine'); + } + } + const router = this.get('router'); + if (action === 'disable') { + return router.transitionTo.call(router, 'vault.cluster.replication.mode', replicationMode); + } + return cluster + .reload() + .then(() => { + cluster.rollbackAttributes(); + if (action === 'enable') { + return router.transitionTo.call(router, 'vault.cluster.replication.mode', replicationMode); + } + + if (mode === 'secondary' && replicationMode === 'dr') { + return router.transitionTo.call(router, 'vault.cluster'); + } + }) + .finally(() => { + this.set('loading', false); + }); + }, + + submitError(e) { + if (e.errors) { + this.set('errors', e.errors); + } else { + throw e; + } + }, +}); diff --git a/ui/app/mixins/unload-model-route.js b/ui/app/mixins/unload-model-route.js new file mode 100644 index 000000000..87a397edd --- /dev/null +++ b/ui/app/mixins/unload-model-route.js @@ -0,0 +1,22 @@ +import Ember from 'ember'; + +// removes Ember Data records from the cache when the model +// changes or you move away from the current route +export default Ember.Mixin.create({ + modelPath: 'model', + unloadModel() { + const model = this.controller.get(this.get('modelPath')); + if (!model || !model.unloadRecord) { + return; + } + this.store.unloadRecord(model); + model.destroy(); + }, + + actions: { + willTransition() { + this.unloadModel(); + return true; + }, + }, +}); diff --git a/ui/app/mixins/unsaved-model-route.js b/ui/app/mixins/unsaved-model-route.js new file mode 100644 index 000000000..89f5c2297 --- /dev/null +++ b/ui/app/mixins/unsaved-model-route.js @@ -0,0 +1,28 @@ +import Ember from 'ember'; +const { get } = Ember; + +// this mixin relies on `unload-model-route` also being used +export default Ember.Mixin.create({ + actions: { + willTransition(transition) { + const model = this.controller.get('model'); + if (!model) { + return true; + } + if (get(model, 'hasDirtyAttributes')) { + if ( + window.confirm( + 'You have unsaved changes. Navigating away will discard these changes. Are you sure you want to discard your changes?' + ) + ) { + this.unloadModel(); + return true; + } else { + transition.abort(); + return false; + } + } + return true; + }, + }, +}); diff --git a/ui/app/models/.gitkeep b/ui/app/models/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/ui/app/models/auth-config.js b/ui/app/models/auth-config.js new file mode 100644 index 000000000..88befe73b --- /dev/null +++ b/ui/app/models/auth-config.js @@ -0,0 +1,6 @@ +import DS from 'ember-data'; +const { belongsTo } = DS; + +export default DS.Model.extend({ + backend: belongsTo('auth-method', { readOnly: true, async: false }), +}); diff --git a/ui/app/models/auth-config/approle.js b/ui/app/models/auth-config/approle.js new file mode 100644 index 000000000..66657e064 --- /dev/null +++ b/ui/app/models/auth-config/approle.js @@ -0,0 +1,2 @@ +import AuthConfig from '../auth-config'; +export default AuthConfig.extend({}); diff --git a/ui/app/models/auth-config/aws/client.js b/ui/app/models/auth-config/aws/client.js new file mode 100644 index 000000000..0bb13bb96 --- /dev/null +++ b/ui/app/models/auth-config/aws/client.js @@ -0,0 +1,33 @@ +import Ember from 'ember'; +import DS from 'ember-data'; +import AuthConfig from '../../auth-config'; +import fieldToAttrs from 'vault/utils/field-to-attrs'; + +const { attr } = DS; +const { computed } = Ember; + +export default AuthConfig.extend({ + secretKey: attr('string'), + accessKey: attr('string'), + endpoint: attr('string', { + label: 'EC2 Endpoint', + }), + iamEndpoint: attr('string', { + label: 'IAM Endpoint', + }), + stsEndpoint: attr('string', { + label: 'STS Endpoint', + }), + iamServerIdHeaderValue: attr('string', { + label: 'IAM Server ID Header Value', + }), + + fieldGroups: computed(function() { + const groups = [ + { default: ['accessKey', 'secretKey'] }, + { 'AWS Options': ['endpoint', 'iamEndpoint', 'stsEndpoint', 'iamServerIdHeaderValue'] }, + ]; + + return fieldToAttrs(this, groups); + }), +}); diff --git a/ui/app/models/auth-config/aws/identity-whitelist.js b/ui/app/models/auth-config/aws/identity-whitelist.js new file mode 100644 index 000000000..a9c065da7 --- /dev/null +++ b/ui/app/models/auth-config/aws/identity-whitelist.js @@ -0,0 +1,2 @@ +import Tidy from './tidy'; +export default Tidy.extend(); diff --git a/ui/app/models/auth-config/aws/roletag-blacklist.js b/ui/app/models/auth-config/aws/roletag-blacklist.js new file mode 100644 index 000000000..a9c065da7 --- /dev/null +++ b/ui/app/models/auth-config/aws/roletag-blacklist.js @@ -0,0 +1,2 @@ +import Tidy from './tidy'; +export default Tidy.extend(); diff --git a/ui/app/models/auth-config/aws/tidy.js b/ui/app/models/auth-config/aws/tidy.js new file mode 100644 index 000000000..daf3949e4 --- /dev/null +++ b/ui/app/models/auth-config/aws/tidy.js @@ -0,0 +1,22 @@ +import DS from 'ember-data'; +import Ember from 'ember'; +import { expandAttributeMeta } from 'vault/utils/field-to-attrs'; +import AuthConfig from '../../auth-config'; + +const { attr } = DS; +const { computed } = Ember; + +export default AuthConfig.extend({ + safetyBuffer: attr({ + defaultValue: '72h', + editType: 'ttl', + }), + + disablePeriodicTidy: attr('boolean', { + defaultValue: false, + }), + + attrs: computed(function() { + return expandAttributeMeta(this, ['safetyBuffer', 'disablePeriodicTidy']); + }), +}); diff --git a/ui/app/models/auth-config/cert.js b/ui/app/models/auth-config/cert.js new file mode 100644 index 000000000..66657e064 --- /dev/null +++ b/ui/app/models/auth-config/cert.js @@ -0,0 +1,2 @@ +import AuthConfig from '../auth-config'; +export default AuthConfig.extend({}); diff --git a/ui/app/models/auth-config/gcp.js b/ui/app/models/auth-config/gcp.js new file mode 100644 index 000000000..6d306e4f0 --- /dev/null +++ b/ui/app/models/auth-config/gcp.js @@ -0,0 +1,26 @@ +import Ember from 'ember'; +import DS from 'ember-data'; + +import AuthConfig from '../auth-config'; +import fieldToAttrs from 'vault/utils/field-to-attrs'; + +const { attr } = DS; +const { computed } = Ember; + +export default AuthConfig.extend({ + credentials: attr('string', { + editType: 'file', + }), + + googleCertsEndpoint: attr('string'), + + fieldGroups: computed(function() { + const groups = [ + { default: ['credentials'] }, + { + 'Google Cloud Options': ['googleCertsEndpoint'], + }, + ]; + return fieldToAttrs(this, groups); + }), +}); diff --git a/ui/app/models/auth-config/github.js b/ui/app/models/auth-config/github.js new file mode 100644 index 000000000..4ffa743b8 --- /dev/null +++ b/ui/app/models/auth-config/github.js @@ -0,0 +1,26 @@ +import Ember from 'ember'; +import DS from 'ember-data'; + +import AuthConfig from '../auth-config'; +import fieldToAttrs from 'vault/utils/field-to-attrs'; + +const { attr } = DS; +const { computed } = Ember; + +export default AuthConfig.extend({ + organization: attr('string'), + baseUrl: attr('string', { + label: 'Base URL', + }), + + fieldGroups: computed(function() { + const groups = [ + { default: ['organization'] }, + { + 'GitHub Options': ['baseUrl'], + }, + ]; + + return fieldToAttrs(this, groups); + }), +}); diff --git a/ui/app/models/auth-config/kubernetes.js b/ui/app/models/auth-config/kubernetes.js new file mode 100644 index 000000000..f7a40214e --- /dev/null +++ b/ui/app/models/auth-config/kubernetes.js @@ -0,0 +1,45 @@ +import Ember from 'ember'; +import DS from 'ember-data'; + +import AuthConfig from '../auth-config'; +import fieldToAttrs from 'vault/utils/field-to-attrs'; + +const { attr } = DS; +const { computed } = Ember; + +export default AuthConfig.extend({ + kubernetesHost: attr('string', { + label: 'Kubernetes Host', + helpText: + 'Host must be a host string, a host:port pair, or a URL to the base of the Kubernetes API server', + }), + + kubernetesCaCert: attr('string', { + label: 'Kubernetes CA Certificate', + editType: 'file', + helpText: 'PEM encoded CA cert for use by the TLS client used to talk with the Kubernetes API', + }), + + tokenReviewerJwt: attr('string', { + label: 'Token Reviewer JWT', + helpText: + 'A service account JWT used to access the TokenReview API to validate other JWTs during login. If not set the JWT used for login will be used to access the API', + }), + + pemKeys: attr({ + label: 'Service account verification keys', + editType: 'stringArray', + }), + + fieldGroups: computed(function() { + const groups = [ + { + default: ['kubernetesHost', 'kubernetesCaCert'], + }, + { + 'Kubernetes Options': ['tokenReviewerJwt', 'pemKeys'], + }, + ]; + return fieldToAttrs(this, groups); + }), +}); diff --git a/ui/app/models/auth-config/ldap.js b/ui/app/models/auth-config/ldap.js new file mode 100644 index 000000000..d2db7b180 --- /dev/null +++ b/ui/app/models/auth-config/ldap.js @@ -0,0 +1,116 @@ +import Ember from 'ember'; +import DS from 'ember-data'; + +import AuthConfig from '../auth-config'; +import fieldToAttrs from 'vault/utils/field-to-attrs'; + +const { attr } = DS; +const { computed } = Ember; + +export default AuthConfig.extend({ + url: attr('string', { + label: 'URL', + }), + starttls: attr('boolean', { + defaultValue: false, + label: 'Issue StartTLS command after establishing an unencrypted connection', + }), + tlsMinVersion: attr('string', { + label: 'Minimum TLS Version', + defaultValue: 'tls12', + possibleValues: ['tls10', 'tls11', 'tls12'], + }), + + tlsMaxVersion: attr('string', { + label: 'Maximum TLS Version', + defaultValue: 'tls12', + possibleValues: ['tls10', 'tls11', 'tls12'], + }), + insecureTls: attr('boolean', { + defaultValue: false, + label: 'Skip LDAP server SSL certificate verification', + }), + certificate: attr('string', { + label: 'CA certificate to verify LDAP server certificate', + editType: 'file', + }), + + binddn: attr('string', { + label: 'Name of Object to bind (binddn)', + helpText: 'Used when performing user search. Example: cn=vault,ou=Users,dc=example,dc=com', + }), + bindpass: attr('string', { + label: 'Password', + helpText: 'Used along with binddn when performing user search', + }), + + userdn: attr('string', { + label: 'User DN', + helpText: 'Base DN under which to perform user search. Example: ou=Users,dc=example,dc=com', + }), + userattr: attr('string', { + label: 'User Attribute', + defaultValue: 'cn', + helpText: + 'Attribute on user attribute object matching the username passed when authenticating. Examples: sAMAccountName, cn, uid', + }), + discoverdn: attr('boolean', { + defaultValue: false, + label: 'Use anonymous bind to discover the bind DN of a user', + }), + denyNullBind: attr('boolean', { + defaultValue: true, + label: 'Prevent users from bypassing authentication when providing an empty password', + }), + upndomain: attr('string', { + label: 'User Principal (UPN) Domain', + helpText: + 'The userPrincipalDomain used to construct the UPN string for the authenticating user. The constructed UPN will appear as [username]@UPNDomain. Example: example.com, which will cause vault to bind as username@example.com.', + }), + + groupfilter: attr('string', { + label: 'Group Filter', + helpText: + 'Go template used when constructing the group membership query. The template can access the following context variables: [UserDN, Username]. The default is (|(memberUid={{.Username}})(member={{.UserDN}})(uniqueMember={{.UserDN}})), which is compatible with several common directory schemas. To support nested group resolution for Active Directory, instead use the following query: (&(objectClass=group)(member:1.2.840.113556.1.4.1941:={{.UserDN}}))', + }), + groupdn: attr('string', { + label: 'Group DN', + helpText: + 'LDAP search base for group membership search. This can be the root containing either groups or users. Example: ou=Groups,dc=example,dc=com', + }), + groupattr: attr('string', { + label: 'Group Attribute', + defaultValue: 'cn', + + helpText: + 'LDAP attribute to follow on objects returned by groupfilter in order to enumerate user group membership. Examples: for groupfilter queries returning group objects, use: cn. For queries returning user objects, use: memberOf. The default is cn.', + }), + + fieldGroups: computed(function() { + const groups = [ + { + default: ['url'], + }, + { + 'LDAP Options': [ + 'starttls', + 'insecureTls', + 'discoverdn', + 'denyNullBind', + 'tlsMinVersion', + 'tlsMaxVersion', + 'certificate', + 'userattr', + 'upndomain', + ], + }, + { + 'Customize User Search': ['binddn', 'userdn', 'bindpass'], + }, + { + 'Customize Group Membership Search': ['groupfilter', 'groupattr', 'groupdn'], + }, + ]; + return fieldToAttrs(this, groups); + }), +}); diff --git a/ui/app/models/auth-config/okta.js b/ui/app/models/auth-config/okta.js new file mode 100644 index 000000000..969db3d91 --- /dev/null +++ b/ui/app/models/auth-config/okta.js @@ -0,0 +1,41 @@ +import Ember from 'ember'; +import DS from 'ember-data'; +import AuthConfig from '../auth-config'; +import fieldToAttrs from 'vault/utils/field-to-attrs'; + +const { attr } = DS; +const { computed } = Ember; + +export default AuthConfig.extend({ + orgName: attr('string', { + label: 'Organization Name', + helpText: 'Name of the organization to be used in the Okta API', + }), + apiToken: attr('string', { + label: 'API Token', + helpText: + 'Okta API token. This is required to query Okta for user group membership. If this is not supplied only locally configured groups will be enabled.', + }), + baseUrl: attr('string', { + label: 'Base URL', + helpText: + 'If set, will be used as the base domain for API requests. Examples are okta.com, oktapreview.com, and okta-emea.com', + }), + bypassOktaMfa: attr('boolean', { + defaultValue: false, + label: 'Bypass Okta MFA', + helpText: + "Useful if Vault's built-in MFA mechanisms. Will also cause certain other statuses to be ignored, such as PASSWORD_EXPIRED", + }), + fieldGroups: computed(function() { + const groups = [ + { + default: ['orgName'], + }, + { + Options: ['apiToken', 'baseUrl', 'bypassOktaMfa'], + }, + ]; + return fieldToAttrs(this, groups); + }), +}); diff --git a/ui/app/models/auth-config/radius.js b/ui/app/models/auth-config/radius.js new file mode 100644 index 000000000..5dc9addc6 --- /dev/null +++ b/ui/app/models/auth-config/radius.js @@ -0,0 +1,42 @@ +import Ember from 'ember'; +import DS from 'ember-data'; +import AuthConfig from '../auth-config'; +import fieldToAttrs from 'vault/utils/field-to-attrs'; + +const { attr } = DS; +const { computed } = Ember; + +export default AuthConfig.extend({ + host: attr('string'), + + port: attr('number', { + defaultValue: 1812, + }), + + secret: attr('string'), + + unregisteredUserPolicies: attr('string', { + label: 'Policies for unregistered users', + }), + + dialTimeout: attr('number', { + defaultValue: 10, + }), + + nasPort: attr('number', { + defaultValue: 10, + label: 'NAS Port', + }), + + fieldGroups: computed(function() { + const groups = [ + { + default: ['host', 'secret'], + }, + { + Options: ['port', 'nasPort', 'dialTimeout', 'unregisteredUserPolicies'], + }, + ]; + return fieldToAttrs(this, groups); + }), +}); diff --git a/ui/app/models/auth-config/userpass.js b/ui/app/models/auth-config/userpass.js new file mode 100644 index 000000000..66657e064 --- /dev/null +++ b/ui/app/models/auth-config/userpass.js @@ -0,0 +1,2 @@ +import AuthConfig from '../auth-config'; +export default AuthConfig.extend({}); diff --git a/ui/app/models/auth-method.js b/ui/app/models/auth-method.js new file mode 100644 index 000000000..adeb1f931 --- /dev/null +++ b/ui/app/models/auth-method.js @@ -0,0 +1,113 @@ +import Ember from 'ember'; +import DS from 'ember-data'; +import { fragment } from 'ember-data-model-fragments/attributes'; +import { queryRecord } from 'ember-computed-query'; +import { methods } from 'vault/helpers/mountable-auth-methods'; +import fieldToAttrs, { expandAttributeMeta } from 'vault/utils/field-to-attrs'; +import { memberAction } from 'ember-api-actions'; + +const { attr, hasMany } = DS; +const { computed } = Ember; + +const METHODS = methods(); + +const configPath = function configPath(strings, key) { + return function(...values) { + return `${strings[0]}${values[key]}${strings[1]}`; + }; +}; +export default DS.Model.extend({ + authConfigs: hasMany('auth-config', { polymorphic: true, inverse: 'backend', async: false }), + path: attr('string', { + defaultValue: METHODS[0].value, + }), + accessor: attr('string'), + name: attr('string'), + type: attr('string', { + defaultValue: METHODS[0].value, + possibleValues: METHODS, + }), + description: attr('string', { + editType: 'textarea', + }), + config: fragment('mount-config', { defaultValue: {} }), + local: attr('boolean'), + sealWrap: attr('boolean'), + + // used when the `auth` prefix is important, + // currently only when setting perf mount filtering + apiPath: computed('path', function() { + return `auth/${this.get('path')}`; + }), + localDisplay: computed('local', function() { + return this.get('local') ? 'local' : 'replicated'; + }), + + tuneAttrs: computed(function() { + return expandAttributeMeta(this, ['description', 'config.{defaultLeaseTtl,maxLeaseTtl}']); + }), + + //sys/mounts/auth/[auth-path]/tune. + tune: memberAction({ + path: 'tune', + type: 'post', + urlType: 'updateRecord', + }), + + formFields: [ + 'type', + 'path', + 'description', + 'accessor', + 'local', + 'sealWrap', + 'config.{defaultLeaseTtl,maxLeaseTtl}', + ], + + formFieldGroups: [ + { default: ['type', 'path'] }, + { 'Method Options': ['description', 'local', 'sealWrap', 'config.{defaultLeaseTtl,maxLeaseTtl}'] }, + ], + + attrs: computed('formFields', function() { + return expandAttributeMeta(this, this.get('formFields')); + }), + + fieldGroups: computed('formFieldGroups', function() { + return fieldToAttrs(this, this.get('formFieldGroups')); + }), + + configPathTmpl: computed('type', function() { + const type = this.get('type'); + if (type === 'aws') { + return configPath`auth/${0}/config/client`; + } else { + return configPath`auth/${0}/config`; + } + }), + + configPath: queryRecord( + 'capabilities', + context => { + const { id, configPathTmpl } = context.getProperties('id', 'configPathTmpl'); + return { + id: configPathTmpl(id), + }; + }, + 'id', + 'configPathTmpl' + ), + deletePath: queryRecord( + 'capabilities', + context => { + const { id } = context.get('id'); + return { + id: `sys/auth/${id}`, + }; + }, + 'id' + ), + canDisable: computed.alias('deletePath.canDelete'), + + canEdit: computed.alias('configPath.canUpdate'), +}); diff --git a/ui/app/models/capabilities.js b/ui/app/models/capabilities.js new file mode 100644 index 000000000..72af44e83 --- /dev/null +++ b/ui/app/models/capabilities.js @@ -0,0 +1,54 @@ +// This model represents the capabilities on a given `path` +// `path` is also the primaryId +// https://www.vaultproject.io/docs/concepts/policies.html#capabilities + +import Ember from 'ember'; +import DS from 'ember-data'; + +const { attr } = DS; + +const SUDO_PATHS = [ + 'sys/seal', + 'sys/replication/performance/primary/secondary-token', + 'sys/replication/dr/primary/secondary-token', + 'sys/replication/reindex', + 'sys/leases/lookup/', +]; + +const SUDO_PATH_PREFIXES = ['sys/leases/revoke-prefix', 'sys/leases/revoke-force']; + +export { SUDO_PATHS, SUDO_PATH_PREFIXES }; + +const computedCapability = function(capability) { + return Ember.computed('path', 'capabilities', 'capabilities.[]', function() { + const capabilities = this.get('capabilities'); + const path = this.get('path'); + if (!capabilities) { + return false; + } + if (capabilities.includes('root')) { + return true; + } + if (capabilities.includes('deny')) { + return false; + } + // if the path is sudo protected, they'll need sudo + the appropriate capability + if (SUDO_PATHS.includes(path) || SUDO_PATH_PREFIXES.find(item => item.startsWith(path))) { + return capabilities.includes('sudo') && capabilities.includes(capability); + } + return capabilities.includes(capability); + }); +}; + +export default DS.Model.extend({ + path: attr('string'), + capabilities: attr('array'), + canSudo: computedCapability('sudo'), + canRead: computedCapability('read'), + canCreate: computedCapability('create'), + canUpdate: computedCapability('update'), + canDelete: computedCapability('delete'), + canList: computedCapability('list'), + allowedParameters: attr(), + deniedParameters: attr(), +}); diff --git a/ui/app/models/cluster.js b/ui/app/models/cluster.js new file mode 100644 index 000000000..673e9f91f --- /dev/null +++ b/ui/app/models/cluster.js @@ -0,0 +1,103 @@ +import Ember from 'ember'; +import DS from 'ember-data'; +import { fragment } from 'ember-data-model-fragments/attributes'; +const { hasMany, attr } = DS; +const { computed, get, inject } = Ember; +const { alias, gte, not } = computed; + +export default DS.Model.extend({ + version: inject.service(), + + nodes: hasMany('nodes', { async: false }), + name: attr('string'), + status: attr('string'), + + needsInit: computed('nodes', 'nodes.[]', function() { + // needs init if no nodes are initialized + return this.get('nodes').isEvery('initialized', false); + }), + + type: computed(function() { + return this.constructor.modelName; + }), + + unsealed: computed('nodes', 'nodes.[]', 'nodes.@each.sealed', function() { + // unsealed if there's at least one unsealed node + return !!this.get('nodes').findBy('sealed', false); + }), + + sealed: not('unsealed'), + + leaderNode: computed('nodes', 'nodes.[]', function() { + const nodes = this.get('nodes'); + if (nodes.get('length') === 1) { + return nodes.get('firstObject'); + } else { + return nodes.findBy('isLeader'); + } + }), + + sealThreshold: alias('leaderNode.sealThreshold'), + sealProgress: alias('leaderNode.progress'), + hasProgress: gte('sealProgress', 1), + + //replication mode - will only ever be 'unsupported' + //otherwise the particular mode will have the relevant mode attr through replication-attributes + mode: attr('string'), + allReplicationDisabled: computed.and('{dr,performance}.replicationDisabled'), + + anyReplicationEnabled: computed.or('{dr,performance}.replicationEnabled'), + + stateDisplay(state) { + if (!state) { + return null; + } + const defaultDisp = 'Synced'; + const displays = { + 'stream-wals': 'Streaming', + 'merkle-diff': 'Determining sync status', + 'merkle-sync': 'Syncing', + }; + + return displays[state] || defaultDisp; + }, + + drStateDisplay: computed('dr.state', function() { + return this.stateDisplay(this.get('dr.state')); + }), + + performanceStateDisplay: computed('performance.state', function() { + return this.stateDisplay(this.get('performance.state')); + }), + + stateGlyph(state) { + const glyph = 'checkmark'; + + const glyphs = { + 'stream-wals': 'android-sync', + 'merkle-diff': 'android-sync', + 'merkle-sync': null, + }; + + return glyphs[state] || glyph; + }, + + drStateGlyph: computed('dr.state', function() { + return this.stateGlyph(this.get('dr.state')); + }), + + performanceStateGlyph: computed('performance.state', function() { + return this.stateGlyph(this.get('performance.state')); + }), + + dr: fragment('replication-attributes'), + performance: fragment('replication-attributes'), + // this service exposes what mode the UI is currently viewing + // replicationAttrs will then return the relevant `replication-attributes` fragment + rm: Ember.inject.service('replication-mode'), + replicationMode: computed.alias('rm.mode'), + replicationAttrs: computed('dr.mode', 'performance.mode', 'replicationMode', function() { + const replicationMode = this.get('replicationMode'); + return replicationMode ? get(this, replicationMode) : null; + }), +}); diff --git a/ui/app/models/iam-credential.js b/ui/app/models/iam-credential.js new file mode 100644 index 000000000..420065e1d --- /dev/null +++ b/ui/app/models/iam-credential.js @@ -0,0 +1,55 @@ +import DS from 'ember-data'; +import Ember from 'ember'; +const { attr } = DS; +const { computed, get } = Ember; +const CREATE_FIELDS = ['ttl']; + +const DISPLAY_FIELDS = ['accessKey', 'secretKey', 'securityToken', 'leaseId', 'renewable', 'leaseDuration']; +export default DS.Model.extend({ + role: attr('object', { + readOnly: true, + }), + + withSTS: attr('boolean', { + readOnly: true, + }), + + ttl: attr({ + editType: 'ttl', + defaultValue: '1h', + }), + leaseId: attr('string'), + renewable: attr('boolean'), + leaseDuration: attr('number'), + accessKey: attr('string'), + secretKey: attr('string'), + securityToken: attr('string'), + + attrs: computed('accessKey', function() { + let keys = this.get('accessKey') ? DISPLAY_FIELDS.slice(0) : CREATE_FIELDS.slice(0); + get(this.constructor, 'attributes').forEach((meta, name) => { + const index = keys.indexOf(name); + if (index === -1) { + return; + } + keys.replace(index, 1, { + type: meta.type, + name, + options: meta.options, + }); + }); + return keys; + }), + + toCreds: computed('accessKey', 'secretKey', 'securityToken', 'leaseId', function() { + const props = this.getProperties('accessKey', 'secretKey', 'securityToken', 'leaseId'); + const propsWithVals = Object.keys(props).reduce((ret, prop) => { + if (props[prop]) { + ret[prop] = props[prop]; + return ret; + } + return ret; + }, {}); + return JSON.stringify(propsWithVals, null, 2); + }), +}); diff --git a/ui/app/models/identity/_base.js b/ui/app/models/identity/_base.js new file mode 100644 index 000000000..e4e5fdd33 --- /dev/null +++ b/ui/app/models/identity/_base.js @@ -0,0 +1,19 @@ +import Ember from 'ember'; +import DS from 'ember-data'; +import { expandAttributeMeta } from 'vault/utils/field-to-attrs'; + +const { assert, computed } = Ember; +export default DS.Model.extend({ + formFields: computed(function() { + return assert('formFields should be overridden', false); + }), + + fields: computed('formFields', 'formFields.[]', function() { + return expandAttributeMeta(this, this.get('formFields')); + }), + + identityType: computed(function() { + let modelType = this.constructor.modelName.split('/')[1]; + return modelType; + }), +}); diff --git a/ui/app/models/identity/entity-alias.js b/ui/app/models/identity/entity-alias.js new file mode 100644 index 000000000..6e5dbaeaf --- /dev/null +++ b/ui/app/models/identity/entity-alias.js @@ -0,0 +1,31 @@ +import IdentityModel from './_base'; +import DS from 'ember-data'; +const { attr, belongsTo } = DS; + +export default IdentityModel.extend({ + formFields: ['name', 'mountAccessor', 'metadata'], + entity: belongsTo('identity/entity', { readOnly: true, async: false }), + + name: attr('string'), + canonicalId: attr('string'), + mountAccessor: attr('string', { + label: 'Auth Backend', + editType: 'mountAccessor', + }), + metadata: attr('object', { + editType: 'kv', + }), + mountPath: attr('string', { + readOnly: true, + }), + mountType: attr('string', { + readOnly: true, + }), + creationTime: attr('string', { + readOnly: true, + }), + lastUpdateTime: attr('string', { + readOnly: true, + }), + mergedFromCanonicalIds: attr(), +}); diff --git a/ui/app/models/identity/entity-merge.js b/ui/app/models/identity/entity-merge.js new file mode 100644 index 000000000..c66293818 --- /dev/null +++ b/ui/app/models/identity/entity-merge.js @@ -0,0 +1,18 @@ +import IdentityModel from './_base'; +import DS from 'ember-data'; +const { attr } = DS; + +export default IdentityModel.extend({ + formFields: ['toEntityId', 'fromEntityIds', 'force'], + toEntityId: attr('string', { + label: 'Entity to merge to', + }), + fromEntityIds: attr({ + label: 'Entities to merge from', + editType: 'stringArray', + }), + force: attr('boolean', { + label: 'Keep MFA secrets from the "to" entity if there are merge conflicts', + defaultValue: false, + }), +}); diff --git a/ui/app/models/identity/entity.js b/ui/app/models/identity/entity.js new file mode 100644 index 000000000..77b685318 --- /dev/null +++ b/ui/app/models/identity/entity.js @@ -0,0 +1,31 @@ +import IdentityModel from './_base'; +import DS from 'ember-data'; +const { attr, hasMany } = DS; + +export default IdentityModel.extend({ + formFields: ['name', 'policies', 'metadata'], + name: attr('string'), + mergedEntityIds: attr(), + metadata: attr('object', { + editType: 'kv', + }), + policies: attr({ + editType: 'stringArray', + }), + creationTime: attr('string', { + readOnly: true, + }), + lastUpdateTime: attr('string', { + readOnly: true, + }), + aliases: hasMany('identity/entity-alias', { async: false, readOnly: true }), + groupIds: attr({ + readOnly: true, + }), + directGroupIds: attr({ + readOnly: true, + }), + inheritedGroupIds: attr({ + readOnly: true, + }), +}); diff --git a/ui/app/models/identity/group-alias.js b/ui/app/models/identity/group-alias.js new file mode 100644 index 000000000..14af8c110 --- /dev/null +++ b/ui/app/models/identity/group-alias.js @@ -0,0 +1,29 @@ +import IdentityModel from './_base'; +import DS from 'ember-data'; +const { attr, belongsTo } = DS; + +export default IdentityModel.extend({ + formFields: ['name', 'mountAccessor'], + group: belongsTo('identity/group', { readOnly: true, async: false }), + + name: attr('string'), + canonicalId: attr('string'), + + mountPath: attr('string', { + readOnly: true, + }), + mountType: attr('string', { + readOnly: true, + }), + mountAccessor: attr('string', { + label: 'Auth Backend', + editType: 'mountAccessor', + }), + + creationTime: attr('string', { + readOnly: true, + }), + lastUpdateTime: attr('string', { + readOnly: true, + }), +}); diff --git a/ui/app/models/identity/group.js b/ui/app/models/identity/group.js new file mode 100644 index 000000000..37212c0c9 --- /dev/null +++ b/ui/app/models/identity/group.js @@ -0,0 +1,55 @@ +import Ember from 'ember'; +import IdentityModel from './_base'; +import DS from 'ember-data'; + +const { computed } = Ember; +const { attr, belongsTo } = DS; + +export default IdentityModel.extend({ + formFields: computed('type', function() { + let fields = ['name', 'type', 'policies', 'metadata']; + if (this.get('type') === 'internal') { + return fields.concat(['memberGroupIds', 'memberEntityIds']); + } + return fields; + }), + name: attr('string'), + type: attr('string', { + defaultValue: 'internal', + possibleValues: ['internal', 'external'], + }), + creationTime: attr('string', { + readOnly: true, + }), + lastUpdateTime: attr('string', { + readOnly: true, + }), + metadata: attr('object', { + editType: 'kv', + }), + policies: attr({ + editType: 'stringArray', + }), + memberGroupIds: attr({ + label: 'Member Group IDs', + editType: 'stringArray', + }), + memberEntityIds: attr({ + label: 'Member Entity IDs', + editType: 'stringArray', + }), + hasMembers: computed( + 'memberEntityIds', + 'memberEntityIds.[]', + 'memberGroupIds', + 'memberGroupIds.[]', + function() { + let { memberEntityIds, memberGroupIds } = this.getProperties('memberEntityIds', 'memberGroupIds'); + let numEntities = (memberEntityIds && memberEntityIds.get('length')) || 0; + let numGroups = (memberGroupIds && memberGroupIds.get('length')) || 0; + return numEntities + numGroups > 0; + } + ), + + alias: belongsTo('identity/group-alias', { async: false, readOnly: true }), +}); diff --git a/ui/app/models/key-mixin.js b/ui/app/models/key-mixin.js new file mode 100644 index 000000000..694e8ce06 --- /dev/null +++ b/ui/app/models/key-mixin.js @@ -0,0 +1,44 @@ +import Ember from 'ember'; +import utils from '../lib/key-utils'; + +export default Ember.Mixin.create({ + flags: null, + + initialParentKey: null, + + isCreating: Ember.computed('initialParentKey', function() { + return this.get('initialParentKey') != null; + }), + + isFolder: Ember.computed('id', function() { + return utils.keyIsFolder(this.get('id')); + }), + + keyParts: Ember.computed('id', function() { + return utils.keyPartsForKey(this.get('id')); + }), + + parentKey: Ember.computed('id', 'isCreating', { + get: function() { + return this.get('isCreating') ? this.get('initialParentKey') : utils.parentKeyForKey(this.get('id')); + }, + set: function(_, value) { + return value; + }, + }), + + keyWithoutParent: Ember.computed('id', 'parentKey', { + get: function() { + var key = this.get('id'); + return key ? key.replace(this.get('parentKey'), '') : null; + }, + set: function(_, value) { + if (value && value.trim()) { + this.set('id', this.get('parentKey') + value); + } else { + this.set('id', null); + } + return value; + }, + }), +}); diff --git a/ui/app/models/lease.js b/ui/app/models/lease.js new file mode 100644 index 000000000..4f0991188 --- /dev/null +++ b/ui/app/models/lease.js @@ -0,0 +1,25 @@ +import Ember from 'ember'; +import DS from 'ember-data'; +import KeyMixin from './key-mixin'; +const { attr } = DS; + +/* sample response +{ + "id": "auth/token/create/25c75065466dfc5f920525feafe47502c4c9915c", + "issue_time": "2017-04-30T10:18:11.228946471-04:00", + "expire_time": "2017-04-30T11:18:11.228946708-04:00", + "last_renewal": null, + "renewable": true, + "ttl": 3558 +} + +*/ + +export default DS.Model.extend(KeyMixin, { + issueTime: attr('string'), + expireTime: attr('string'), + lastRenewal: attr('string'), + renewable: attr('boolean'), + ttl: attr('number'), + isAuthLease: Ember.computed.match('id', /^auth/), +}); diff --git a/ui/app/models/mount-config.js b/ui/app/models/mount-config.js new file mode 100644 index 000000000..35bbf19f1 --- /dev/null +++ b/ui/app/models/mount-config.js @@ -0,0 +1,13 @@ +import attr from 'ember-data/attr'; +import Fragment from 'ember-data-model-fragments/fragment'; + +export default Fragment.extend({ + defaultLeaseTtl: attr({ + label: 'Default Lease TTL', + editType: 'ttl', + }), + maxLeaseTtl: attr({ + label: 'Max Lease TTL', + editType: 'ttl', + }), +}); diff --git a/ui/app/models/mount-filter-config.js b/ui/app/models/mount-filter-config.js new file mode 100644 index 000000000..b0234a749 --- /dev/null +++ b/ui/app/models/mount-filter-config.js @@ -0,0 +1,13 @@ +import DS from 'ember-data'; +const { attr } = DS; + +export default DS.Model.extend({ + mode: attr('string', { + defaultValue: 'whitelist', + }), + paths: attr('array', { + defaultValue: function() { + return []; + }, + }), +}); diff --git a/ui/app/models/mount-options.js b/ui/app/models/mount-options.js new file mode 100644 index 000000000..49bffe2ff --- /dev/null +++ b/ui/app/models/mount-options.js @@ -0,0 +1,6 @@ +import attr from 'ember-data/attr'; +import Fragment from 'ember-data-model-fragments/fragment'; + +export default Fragment.extend({ + versioned: attr('string'), +}); diff --git a/ui/app/models/node.js b/ui/app/models/node.js new file mode 100644 index 000000000..5d9b648c9 --- /dev/null +++ b/ui/app/models/node.js @@ -0,0 +1,38 @@ +import Ember from 'ember'; +import DS from 'ember-data'; +const { attr } = DS; + +const { computed } = Ember; +const { equal, and, alias } = computed; + +export default DS.Model.extend({ + name: attr('string'), + //https://www.vaultproject.io/docs/http/sys-health.html + initialized: attr('boolean'), + sealed: attr('boolean'), + isSealed: alias('sealed'), + standby: attr('boolean'), + isActive: equal('standby', false), + clusterName: attr('string'), + clusterId: attr('string'), + + isLeader: and('initialized', 'isActive'), + + //https://www.vaultproject.io/docs/http/sys-seal-status.html + //The "t" parameter is the threshold, and "n" is the number of shares. + t: attr('number'), + n: attr('number'), + progress: attr('number'), + sealThreshold: alias('t'), + sealNumShares: alias('n'), + version: attr('string'), + + //https://www.vaultproject.io/docs/http/sys-leader.html + haEnabled: attr('boolean'), + isSelf: attr('boolean'), + leaderAddress: attr('string'), + + type: Ember.computed(function() { + return this.constructor.modelName; + }), +}); diff --git a/ui/app/models/pki-ca-certificate-sign.js b/ui/app/models/pki-ca-certificate-sign.js new file mode 100644 index 000000000..25617eec9 --- /dev/null +++ b/ui/app/models/pki-ca-certificate-sign.js @@ -0,0 +1,77 @@ +import Ember from 'ember'; +import DS from 'ember-data'; +import Certificate from './pki-certificate-sign'; + +const { attr } = DS; +const { computed } = Ember; + +export default Certificate.extend({ + backend: attr('string', { + readOnly: true, + }), + useCsrValues: attr('boolean', { + defaultValue: false, + label: 'Use CSR values', + }), + maxPathLength: attr('number', { + defaultValue: -1, + }), + permittedDnsNames: attr('string', { + label: 'Permitted DNS domains', + }), + ou: attr({ + label: 'OU (OrganizationalUnit)', + editType: 'stringArray', + }), + organization: attr({ + editType: 'stringArray', + }), + country: attr({ + editType: 'stringArray', + }), + locality: attr({ + editType: 'stringArray', + label: 'Locality/City', + }), + province: attr({ + editType: 'stringArray', + label: 'Province/State', + }), + streetAddress: attr({ + editType: 'stringArray', + }), + postalCode: attr({ + editType: 'stringArray', + }), + + fieldGroups: computed('useCsrValues', function() { + const options = [ + { + Options: [ + 'altNames', + 'ipSans', + 'ttl', + 'excludeCnFromSans', + 'maxPathLength', + 'permittedDnsNames', + 'ou', + 'organization', + 'otherSans', + ], + }, + { + 'Address Options': ['country', 'locality', 'province', 'streetAddress', 'postalCode'], + }, + ]; + let groups = [ + { + default: ['csr', 'commonName', 'format', 'useCsrValues'], + }, + ]; + if (this.get('useCsrValues') === false) { + groups = groups.concat(options); + } + + return this.fieldsToAttrs(Ember.copy(groups, true)); + }), +}); diff --git a/ui/app/models/pki-ca-certificate.js b/ui/app/models/pki-ca-certificate.js new file mode 100644 index 000000000..472339bb0 --- /dev/null +++ b/ui/app/models/pki-ca-certificate.js @@ -0,0 +1,154 @@ +import Certificate from './pki-certificate'; +import Ember from 'ember'; +import DS from 'ember-data'; +import { queryRecord } from 'ember-computed-query'; + +const { computed } = Ember; +const { attr } = DS; + +export default Certificate.extend({ + DISPLAY_FIELDS: [ + 'csr', + 'certificate', + 'expiration', + 'issuingCa', + 'caChain', + 'privateKey', + 'privateKeyType', + 'serialNumber', + ], + backend: attr('string', { + readOnly: true, + }), + + caType: attr('string', { + possibleValues: ['root', 'intermediate'], + defaultValue: 'root', + label: 'CA Type', + readOnly: true, + }), + uploadPemBundle: attr('boolean', { + label: 'Upload PEM bundle', + readOnly: true, + }), + pemBundle: attr('string', { + label: 'PEM bundle', + editType: 'file', + }), + + fieldDefinition: computed('caType', 'uploadPemBundle', function() { + const type = this.get('caType'); + const isUpload = this.get('uploadPemBundle'); + let groups = [{ default: ['caType', 'uploadPemBundle'] }]; + if (isUpload) { + groups[0].default.push('pemBundle'); + } else { + groups[0].default.push('type', 'commonName'); + if (type === 'root') { + groups.push({ + Options: [ + 'altNames', + 'ipSans', + 'ttl', + 'format', + 'privateKeyFormat', + 'keyType', + 'keyBits', + 'maxPathLength', + 'permittedDnsNames', + 'excludeCnFromSans', + 'ou', + 'organization', + 'otherSans', + ], + }); + } + if (type === 'intermediate') { + groups.push({ + Options: [ + 'altNames', + 'ipSans', + 'format', + 'privateKeyFormat', + 'keyType', + 'keyBits', + 'excludeCnFromSans', + 'ou', + 'organization', + 'otherSans', + ], + }); + } + } + groups.push({ + 'Address Options': ['country', 'locality', 'province', 'streetAddress', 'postalCode'], + }); + + return groups; + }), + + type: attr('string', { + possibleValues: ['internal', 'exported'], + defaultValue: 'internal', + }), + ou: attr({ + label: 'OU (OrganizationalUnit)', + editType: 'stringArray', + }), + organization: attr({ + editType: 'stringArray', + }), + country: attr({ + editType: 'stringArray', + }), + locality: attr({ + editType: 'stringArray', + label: 'Locality/City', + }), + province: attr({ + editType: 'stringArray', + label: 'Province/State', + }), + streetAddress: attr({ + editType: 'stringArray', + }), + postalCode: attr({ + editType: 'stringArray', + }), + + keyType: attr('string', { + possibleValues: ['rsa', 'ec'], + defaultValue: 'rsa', + }), + keyBits: attr('number', { + defaultValue: 2048, + }), + privateKeyFormat: attr('string', { + possibleValues: ['', 'der', 'pem', 'pkcs8'], + defaultValue: '', + }), + maxPathLength: attr('number', { + defaultValue: -1, + }), + permittedDnsNames: attr('string', { + label: 'Permitted DNS domains', + }), + + csr: attr('string', { + editType: 'textarea', + label: 'CSR', + }), + expiration: attr(), + + deletePath: queryRecord( + 'capabilities', + context => { + const { backend } = context.getProperties('backend'); + return { + id: `${backend}/root`, + }; + }, + 'backend' + ), + canDeleteRoot: computed.and('deletePath.canDelete', 'deletePath.canSudo'), +}); diff --git a/ui/app/models/pki-certificate-sign.js b/ui/app/models/pki-certificate-sign.js new file mode 100644 index 000000000..9fa95dc9e --- /dev/null +++ b/ui/app/models/pki-certificate-sign.js @@ -0,0 +1,32 @@ +import Ember from 'ember'; +import DS from 'ember-data'; +import Certificate from './pki-certificate'; + +const { attr } = DS; +const { computed } = Ember; + +export default Certificate.extend({ + signVerbatim: attr('boolean', { + readOnly: true, + defaultValue: false, + }), + + csr: attr('string', { + label: 'Certificate Signing Request (CSR)', + editType: 'textarea', + }), + + fieldGroups: computed('signVerbatim', function() { + const options = { Options: ['altNames', 'ipSans', 'ttl', 'excludeCnFromSans', 'otherSans'] }; + const groups = [ + { + default: ['csr', 'commonName', 'format', 'signVerbatim'], + }, + ]; + if (this.get('signVerbatim') === false) { + groups.push(options); + } + + return this.fieldsToAttrs(Ember.copy(groups, true)); + }), +}); diff --git a/ui/app/models/pki-certificate.js b/ui/app/models/pki-certificate.js new file mode 100644 index 000000000..75be9f8b0 --- /dev/null +++ b/ui/app/models/pki-certificate.js @@ -0,0 +1,159 @@ +import Ember from 'ember'; +import DS from 'ember-data'; +import { queryRecord } from 'ember-computed-query'; + +const { computed, get } = Ember; +const { attr } = DS; + +export default DS.Model.extend({ + idPrefix: 'cert/', + + backend: attr('string', { + readOnly: true, + }), + //the id prefixed with `cert/` so we can use it as the *secret param for the secret show route + idForNav: attr('string', { + readOnly: true, + }), + DISPLAY_FIELDS: [ + 'certificate', + 'issuingCa', + 'caChain', + 'privateKey', + 'privateKeyType', + 'serialNumber', + 'revocationTime', + ], + role: attr('object', { + readOnly: true, + }), + + revocationTime: attr('number'), + commonName: attr('string', { + label: 'Common Name', + }), + + altNames: attr('string', { + label: 'DNS/Email Subject Alternative Names (SANs)', + }), + + ipSans: attr('string', { + label: 'IP Subject Alternative Names (SANs)', + }), + otherSans: attr({ + editType: 'stringArray', + label: 'Other SANs', + helpText: + 'The format is the same as OpenSSL: ;: where the only current valid type is UTF8', + }), + + ttl: attr({ + label: 'TTL', + editType: 'ttl', + }), + + format: attr('string', { + defaultValue: 'pem', + possibleValues: ['pem', 'der', 'pem_bundle'], + }), + + excludeCnFromSans: attr('boolean', { + label: 'Exclude Common Name from Subject Alternative Names (SANs)', + defaultValue: false, + }), + + certificate: attr('string'), + issuingCa: attr('string', { + label: 'Issuing CA', + }), + caChain: attr('string', { + label: 'CA chain', + }), + privateKey: attr('string'), + privateKeyType: attr('string'), + serialNumber: attr('string'), + + fieldsToAttrs(fieldGroups) { + const attrMap = get(this.constructor, 'attributes'); + return fieldGroups.map(group => { + const groupKey = Object.keys(group)[0]; + const groupMembers = group[groupKey]; + const fields = groupMembers.map(field => { + var meta = attrMap.get(field); + return { + type: meta.type, + name: meta.name, + options: meta.options, + }; + }); + return { [groupKey]: fields }; + }); + }, + + fieldDefinition: computed(function() { + const groups = [ + { default: ['commonName', 'format'] }, + { Options: ['altNames', 'ipSans', 'ttl', 'excludeCnFromSans', 'otherSans'] }, + ]; + return groups; + }), + + fieldGroups: computed('fieldDefinition', function() { + return this.fieldsToAttrs(this.get('fieldDefinition')); + }), + + attrs: computed('certificate', 'csr', function() { + let keys = this.get('certificate') || this.get('csr') ? this.DISPLAY_FIELDS.slice(0) : []; + const attrMap = get(this.constructor, 'attributes'); + keys = keys.map(key => { + let meta = attrMap.get(key); + return { + type: meta.type, + name: meta.name, + options: meta.options, + }; + }); + return keys; + }), + + toCreds: computed( + 'certificate', + 'issuingCa', + 'caChain', + 'privateKey', + 'privateKeyType', + 'revocationTime', + 'serialNumber', + function() { + const props = this.getProperties( + 'certificate', + 'issuingCa', + 'caChain', + 'privateKey', + 'privateKeyType', + 'revocationTime', + 'serialNumber' + ); + const propsWithVals = Object.keys(props).reduce((ret, prop) => { + if (props[prop]) { + ret[prop] = props[prop]; + return ret; + } + return ret; + }, {}); + return JSON.stringify(propsWithVals, null, 2); + } + ), + + revokePath: queryRecord( + 'capabilities', + context => { + const { backend } = context.getProperties('backend'); + return { + id: `${backend}/revoke`, + }; + }, + 'backend' + ), + canRevoke: computed.alias('revokePath.canUpdate'), +}); diff --git a/ui/app/models/pki-config.js b/ui/app/models/pki-config.js new file mode 100644 index 000000000..c471db069 --- /dev/null +++ b/ui/app/models/pki-config.js @@ -0,0 +1,69 @@ +import Ember from 'ember'; +import DS from 'ember-data'; + +const { attr } = DS; +const { computed, get } = Ember; + +export default DS.Model.extend({ + backend: attr('string'), + der: attr(), + pem: attr('string'), + caChain: attr('string'), + attrList(keys) { + const attrMap = get(this.constructor, 'attributes'); + keys = keys.map(key => { + let meta = attrMap.get(key); + return { + type: meta.type, + name: meta.name, + options: meta.options, + }; + }); + return keys; + }, + + //urls + urlsAttrs: computed(function() { + let keys = ['issuingCertificates', 'crlDistributionPoints', 'ocspServers']; + return this.attrList(keys); + }), + issuingCertificates: attr({ + editType: 'stringArray', + }), + crlDistributionPoints: attr({ + label: 'CRL Distribution Points', + editType: 'stringArray', + }), + ocspServers: attr({ + label: 'OCSP Servers', + editType: 'stringArray', + }), + + //tidy + tidyAttrs: computed(function() { + let keys = ['tidyCertStore', 'tidyRevocationList', 'safetyBuffer']; + return this.attrList(keys); + }), + tidyCertStore: attr('boolean', { + defaultValue: false, + label: 'Tidy the Certificate Store', + }), + tidyRevocationList: attr('boolean', { + defaultValue: false, + label: 'Tidy the Revocation List (CRL)', + }), + safetyBuffer: attr({ + defaultValue: '72h', + editType: 'ttl', + }), + + crlAttrs: computed(function() { + let keys = ['expiry']; + return this.attrList(keys); + }), + //crl + expiry: attr({ + defaultValue: '72h', + editType: 'ttl', + }), +}); diff --git a/ui/app/models/policy.js b/ui/app/models/policy.js new file mode 100644 index 000000000..785aa6237 --- /dev/null +++ b/ui/app/models/policy.js @@ -0,0 +1,46 @@ +import DS from 'ember-data'; +import Ember from 'ember'; +import { queryRecord } from 'ember-computed-query'; + +let { attr } = DS; +let { computed } = Ember; + +export default DS.Model.extend({ + name: attr('string'), + policy: attr('string'), + policyType: computed(function() { + return this.constructor.modelName.split('/')[1]; + }), + + updatePath: queryRecord( + 'capabilities', + context => { + const { policyType, id } = context.getProperties('policyType', 'id'); + if (!policyType && id) { + return; + } + return { + id: `sys/${policyType}/policies/${id}`, + }; + }, + 'id', + 'policyType' + ), + canDelete: computed.alias('updatePath.canDelete'), + canEdit: computed.alias('updatePath.canUpdate'), + canRead: computed.alias('updatePath.canRead'), + format: computed('policy', function() { + let policy = this.get('policy'); + let isJSON; + try { + let parsed = JSON.parse(policy); + if (parsed) { + isJSON = true; + } + } catch (e) { + // can't parse JSON + isJSON = false; + } + return isJSON ? 'json' : 'hcl'; + }), +}); diff --git a/ui/app/models/policy/acl.js b/ui/app/models/policy/acl.js new file mode 100644 index 000000000..fd700b574 --- /dev/null +++ b/ui/app/models/policy/acl.js @@ -0,0 +1,3 @@ +import PolicyModel from '../policy'; + +export default PolicyModel.extend(); diff --git a/ui/app/models/policy/egp.js b/ui/app/models/policy/egp.js new file mode 100644 index 000000000..d6dece528 --- /dev/null +++ b/ui/app/models/policy/egp.js @@ -0,0 +1,17 @@ +import DS from 'ember-data'; +import Ember from 'ember'; + +import PolicyModel from './rgp'; +import { expandAttributeMeta } from 'vault/utils/field-to-attrs'; + +let { attr } = DS; +let { computed } = Ember; + +export default PolicyModel.extend({ + paths: attr({ + editType: 'stringArray', + }), + additionalAttrs: computed(function() { + return expandAttributeMeta(this, ['enforcementLevel', 'paths']); + }), +}); diff --git a/ui/app/models/policy/rgp.js b/ui/app/models/policy/rgp.js new file mode 100644 index 000000000..361d669b3 --- /dev/null +++ b/ui/app/models/policy/rgp.js @@ -0,0 +1,19 @@ +import DS from 'ember-data'; +import Ember from 'ember'; + +import PolicyModel from '../policy'; +import { expandAttributeMeta } from 'vault/utils/field-to-attrs'; + +let { attr } = DS; +let { computed } = Ember; + +export default PolicyModel.extend({ + enforcementLevel: attr('string', { + possibleValues: ['advisory', 'soft-mandatory', 'hard-mandatory'], + defaultValue: 'hard-mandatory', + }), + + additionalAttrs: computed(function() { + return expandAttributeMeta(this, ['enforcementLevel']); + }), +}); diff --git a/ui/app/models/replication-attributes.js b/ui/app/models/replication-attributes.js new file mode 100644 index 000000000..a2a981e3a --- /dev/null +++ b/ui/app/models/replication-attributes.js @@ -0,0 +1,77 @@ +import Ember from 'ember'; +import attr from 'ember-data/attr'; +import Fragment from 'ember-data-model-fragments/fragment'; + +const { computed } = Ember; +const { match } = computed; + +export default Fragment.extend({ + clusterId: attr('string'), + clusterIdDisplay: computed('mode', function() { + const clusterId = this.get('clusterId'); + return clusterId ? clusterId.split('-')[0] : null; + }), + mode: attr('string'), + replicationDisabled: match('mode', /disabled|unsupported/), + replicationUnsupported: match('mode', /unsupported/), + replicationEnabled: computed.not('replicationDisabled'), + + // primary attrs + isPrimary: match('mode', /primary/), + + knownSecondaries: attr('array'), + + // secondary attrs + isSecondary: match('mode', /secondary/), + + modeForUrl: computed('mode', function() { + const mode = this.get('mode'); + return mode === 'bootstrapping' + ? 'bootstrapping' + : (this.get('isSecondary') && 'secondary') || (this.get('isPrimary') && 'primary'); + }), + secondaryId: attr('string'), + primaryClusterAddr: attr('string'), + knownPrimaryClusterAddrs: attr('array'), + state: attr('string'), //stream-wal, merkle-diff, merkle-sync, idle + lastRemoteWAL: attr('number'), + + // attrs on primary and secondary + lastWAL: attr('number'), + merkleRoot: attr('string'), + merkleSyncProgress: attr('object'), + syncProgress: computed('state', 'merkleSyncProgress', function() { + const { state, merkleSyncProgress } = this.getProperties('state', 'merkleSyncProgress'); + if (state !== 'merkle-sync' || !merkleSyncProgress) { + return null; + } + const { sync_total_keys, sync_progress } = merkleSyncProgress; + return { + progress: sync_progress, + total: sync_total_keys, + }; + }).volatile(), + + syncProgressPercent: computed('syncProgress', function() { + const syncProgress = this.get('syncProgress'); + if (!syncProgress) { + return null; + } + const { progress, total } = syncProgress; + + return Math.floor(100 * (progress / total)); + }), + + modeDisplay: computed('mode', function() { + const displays = { + disabled: 'Disabled', + unknown: 'Unknown', + bootstrapping: 'Bootstrapping', + primary: 'Primary', + secondary: 'Secondary', + unsupported: 'Not supported', + }; + + return displays[this.get('mode')] || 'Disabled'; + }), +}); diff --git a/ui/app/models/role-aws.js b/ui/app/models/role-aws.js new file mode 100644 index 000000000..a1d6e0738 --- /dev/null +++ b/ui/app/models/role-aws.js @@ -0,0 +1,81 @@ +import Ember from 'ember'; +import DS from 'ember-data'; +import { queryRecord } from 'ember-computed-query'; + +const { attr } = DS; +const { computed, get } = Ember; + +const CREATE_FIELDS = ['name', 'policy', 'arn']; +export default DS.Model.extend({ + backend: attr('string', { + readOnly: true, + }), + name: attr('string', { + label: 'Role name', + fieldValue: 'id', + readOnly: true, + }), + arn: attr('string', { + helpText: '', + }), + policy: attr('string', { + helpText: '', + widget: 'json', + }), + attrs: computed(function() { + let keys = CREATE_FIELDS.slice(0); + get(this.constructor, 'attributes').forEach((meta, name) => { + const index = keys.indexOf(name); + if (index === -1) { + return; + } + keys.replace(index, 1, { + type: meta.type, + name, + options: meta.options, + }); + }); + return keys; + }), + + updatePath: queryRecord( + 'capabilities', + context => { + const { backend, id } = context.getProperties('backend', 'id'); + return { + id: `${backend}/roles/${id}`, + }; + }, + 'id', + 'backend' + ), + canDelete: computed.alias('updatePath.canDelete'), + canEdit: computed.alias('updatePath.canUpdate'), + canRead: computed.alias('updatePath.canRead'), + + generatePath: queryRecord( + 'capabilities', + context => { + const { backend, id } = context.getProperties('backend', 'id'); + return { + id: `${backend}/creds/${id}`, + }; + }, + 'id', + 'backend' + ), + canGenerate: computed.alias('generatePath.canUpdate'), + + stsPath: queryRecord( + 'capabilities', + context => { + const { backend, id } = context.getProperties('backend', 'id'); + return { + id: `${backend}/sts/${id}`, + }; + }, + 'id', + 'backend' + ), + canGenerateSTS: computed.alias('stsPath.canUpdate'), +}); diff --git a/ui/app/models/role-pki.js b/ui/app/models/role-pki.js new file mode 100644 index 000000000..0a7251d78 --- /dev/null +++ b/ui/app/models/role-pki.js @@ -0,0 +1,221 @@ +import Ember from 'ember'; +import DS from 'ember-data'; +import { queryRecord } from 'ember-computed-query'; + +const { attr } = DS; +const { computed, get } = Ember; + +export default DS.Model.extend({ + backend: attr('string', { + readOnly: true, + }), + name: attr('string', { + label: 'Role name', + fieldValue: 'id', + readOnly: true, + }), + keyType: attr('string', { + possibleValues: ['rsa', 'ec'], + }), + ttl: attr({ + label: 'TTL', + editType: 'ttl', + }), + maxTtl: attr({ + label: 'Max TTL', + editType: 'ttl', + }), + allowLocalhost: attr('boolean', {}), + allowedDomains: attr('string', {}), + allowBareDomains: attr('boolean', {}), + allowSubdomains: attr('boolean', {}), + allowGlobDomains: attr('boolean', {}), + allowAnyName: attr('boolean', {}), + enforceHostnames: attr('boolean', {}), + allowIpSans: attr('boolean', { + defaultValue: true, + label: 'Allow clients to request IP Subject Alternative Names (SANs)', + }), + allowedOtherSans: attr({ + editType: 'stringArray', + label: 'Allowed Other SANs', + }), + serverFlag: attr('boolean', { + defaultValue: true, + }), + clientFlag: attr('boolean', { + defaultValue: true, + }), + codeSigningFlag: attr('boolean', {}), + emailProtectionFlag: attr('boolean', {}), + keyBits: attr('number', { + defaultValue: 2048, + }), + keyUsage: attr('string', { + defaultValue: 'DigitalSignature,KeyAgreement,KeyEncipherment', + editType: 'stringArray', + }), + useCsrCommonName: attr('boolean', { + label: 'Use CSR common name', + defaultValue: true, + }), + useCsrSans: attr('boolean', { + defaultValue: true, + label: 'Use CSR subject alternative names (SANs)', + }), + ou: attr({ + label: 'OU (OrganizationalUnit)', + editType: 'stringArray', + }), + organization: attr({ + editType: 'stringArray', + }), + country: attr({ + editType: 'stringArray', + }), + locality: attr({ + editType: 'stringArray', + label: 'Locality/City', + }), + province: attr({ + editType: 'stringArray', + label: 'Province/State', + }), + streetAddress: attr({ + editType: 'stringArray', + }), + postalCode: attr({ + editType: 'stringArray', + }), + generateLease: attr('boolean', {}), + noStore: attr('boolean', {}), + + updatePath: queryRecord( + 'capabilities', + context => { + const { backend, id } = context.getProperties('backend', 'id'); + return { + id: `${backend}/roles/${id}`, + }; + }, + 'id', + 'backend' + ), + canDelete: computed.alias('updatePath.canDelete'), + canEdit: computed.alias('updatePath.canUpdate'), + canRead: computed.alias('updatePath.canRead'), + + generatePath: queryRecord( + 'capabilities', + context => { + const { backend, id } = context.getProperties('backend', 'id'); + return { + id: `${backend}/issue/${id}`, + }; + }, + 'id', + 'backend' + ), + canGenerate: computed.alias('generatePath.canUpdate'), + + signPath: queryRecord( + 'capabilities', + context => { + const { backend, id } = context.getProperties('backend', 'id'); + return { + id: `${backend}/sign/${id}`, + }; + }, + 'id', + 'backend' + ), + canSign: computed.alias('signPath.canUpdate'), + + signVerbatimPath: queryRecord( + 'capabilities', + context => { + const { backend, id } = context.getProperties('backend', 'id'); + return { + id: `${backend}/sign-verbatim/${id}`, + }; + }, + 'id', + 'backend' + ), + canSignVerbatim: computed.alias('signVerbatimPath.canUpdate'), + + /* + * this hydrates the map in `fieldGroups` so that it contains + * the actual field information, not just the name of the field + */ + fieldsToAttrs(fieldGroups) { + const attrMap = get(this.constructor, 'attributes'); + return fieldGroups.map(group => { + const groupKey = Object.keys(group)[0]; + const groupMembers = group[groupKey]; + const fields = groupMembers.map(field => { + var meta = attrMap.get(field); + return { + type: meta.type, + name: meta.name, + options: meta.options, + }; + }); + return { [groupKey]: fields }; + }); + }, + + /* + * returns an array of objects that list attributes so that the form can be programmatically generated + * the attributes are pulled from the model's attribute hash + * + * The keys will be used to label each section of the form. + * the 'default' key contains fields that are outside of any grouping + * + * returns an array of objects: + * + * [ + * {'default': [ { type: 'string', name: 'keyType', options: { label: 'Key Type'}}]}, + * {'Options': [{ type: 'boolean', name: 'allowAnyName', options: {}}]} + * ] + * + * + */ + fieldGroups: computed(function() { + const groups = [ + { default: ['name', 'keyType'] }, + { + Options: [ + 'keyBits', + 'ttl', + 'maxTtl', + 'allowAnyName', + 'enforceHostnames', + 'allowIpSans', + 'useCsrCommonName', + 'useCsrSans', + 'ou', + 'organization', + 'keyUsage', + 'allowedOtherSans', + ], + }, + { + 'Address Options': ['country', 'locality', 'province', 'streetAddress', 'postalCode'], + }, + { + 'Domain Handling': [ + 'allowLocalhost', + 'allowBareDomains', + 'allowSubdomains', + 'allowGlobDomains', + 'allowedDomains', + ], + }, + { 'Extended Key Usage': ['serverFlag', 'clientFlag', 'codeSigningFlag', 'emailProtectionFlag'] }, + { Advanced: ['generateLease', 'noStore'] }, + ]; + + return this.fieldsToAttrs(Ember.copy(groups, true)); + }), +}); diff --git a/ui/app/models/role-ssh.js b/ui/app/models/role-ssh.js new file mode 100644 index 000000000..22b2c2cb4 --- /dev/null +++ b/ui/app/models/role-ssh.js @@ -0,0 +1,193 @@ +import Ember from 'ember'; +import DS from 'ember-data'; +import { queryRecord } from 'ember-computed-query'; + +const { attr } = DS; +const { computed, get } = Ember; + +// these arrays define the order in which the fields will be displayed +// see +// https://github.com/hashicorp/vault/blob/master/builtin/logical/ssh/path_roles.go#L542 for list of fields for each key type +const OTP_FIELDS = [ + 'name', + 'keyType', + 'defaultUser', + 'adminUser', + 'port', + 'allowedUsers', + 'cidrList', + 'excludeCidrList', +]; +const CA_FIELDS = [ + 'name', + 'keyType', + 'allowUserCertificates', + 'allowHostCertificates', + 'defaultUser', + 'allowedUsers', + 'allowedDomains', + 'ttl', + 'maxTtl', + 'allowedCriticalOptions', + 'defaultCriticalOptions', + 'allowedExtensions', + 'defaultExtensions', + 'allowBareDomains', + 'allowSubdomains', + 'allowUserKeyIds', + 'keyIdFormat', +]; +export default DS.Model.extend({ + zeroAddress: attr('boolean', { + readOnly: true, + }), + backend: attr('string', { + readOnly: true, + }), + name: attr('string', { + label: 'Role name', + fieldValue: 'id', + readOnly: true, + }), + keyType: attr('string', { + possibleValues: ['ca', 'otp'], + }), + adminUser: attr('string', { + helpText: 'Username of the admin user at the remote host', + }), + defaultUser: attr('string', { + helpText: "Username to use when one isn't specified", + }), + allowedUsers: attr('string', { + helpText: + 'Create a whitelist of users that can use this key (e.g. `admin, dev`, or use `*` to allow all)', + }), + allowedDomains: attr('string', { + helpText: + 'List of domains for which a client can request a certificate (e.g. `example.com`, or `*` to allow all)', + }), + cidrList: attr('string', { + label: 'CIDR list', + helpText: 'List of CIDR blocks for which this role is applicable', + }), + excludeCidrList: attr('string', { + label: 'Exclude CIDR list', + helpText: 'List of CIDR blocks that are not accepted by this role', + }), + port: attr('number', { + defaultValue: 22, + helpText: 'Port number for the SSH connection (default is `22`)', + }), + ttl: attr({ + label: 'TTL', + editType: 'ttl', + }), + maxTtl: attr({ + label: 'Max TTL', + editType: 'ttl', + }), + allowedCriticalOptions: attr('string', { + helpText: 'List of critical options that certificates have when signed', + }), + defaultCriticalOptions: attr('object', { + helpText: 'Map of critical options certificates should have if none are provided when signing', + }), + allowedExtensions: attr('string', { + helpText: 'List of extensions that certificates can have when signed', + }), + defaultExtensions: attr('object', { + helpText: 'Map of extensions certificates should have if none are provided when signing', + }), + allowUserCertificates: attr('boolean', { + helpText: 'Specifies if certificates are allowed to be signed for us as a user', + }), + allowHostCertificates: attr('boolean', { + helpText: 'Specifies if certificates are allowed to be signed for us as a host', + }), + allowBareDomains: attr('boolean', { + helpText: + 'Specifies if host certificates that are requested are allowed to use the base domains listed in Allowed Domains', + }), + allowSubdomains: attr('boolean', { + helpText: + 'Specifies if host certificates that are requested are allowed to be subdomains of those listed in Allowed Domains', + }), + allowUserKeyIds: attr('boolean', { + label: 'Allow user key IDs', + helpText: 'Specifies if users can override the key ID for a signed certificate with the "key_id" field', + }), + keyIdFormat: attr('string', { + label: 'Key ID format', + helpText: 'When supplied, this value specifies a custom format for the key id of a signed certificate', + }), + + attrsForKeyType: computed('keyType', function() { + const keyType = this.get('keyType'); + let keys = keyType === 'ca' ? CA_FIELDS.slice(0) : OTP_FIELDS.slice(0); + get(this.constructor, 'attributes').forEach((meta, name) => { + const index = keys.indexOf(name); + if (index === -1) { + return; + } + keys.replace(index, 1, { + type: meta.type, + name, + options: meta.options, + }); + }); + return keys; + }), + + updatePath: queryRecord( + 'capabilities', + context => { + const { backend, id } = context.getProperties('backend', 'id'); + return { + id: `${backend}/roles/${id}`, + }; + }, + 'id', + 'backend' + ), + canDelete: computed.alias('updatePath.canDelete'), + canEdit: computed.alias('updatePath.canUpdate'), + canRead: computed.alias('updatePath.canRead'), + + generatePath: queryRecord( + 'capabilities', + context => { + const { backend, id } = context.getProperties('backend', 'id'); + return { + id: `${backend}/creds/${id}`, + }; + }, + 'id', + 'backend' + ), + canGenerate: computed.alias('generatePath.canUpdate'), + + signPath: queryRecord( + 'capabilities', + context => { + const { backend, id } = context.getProperties('backend', 'id'); + return { + id: `${backend}/sign/${id}`, + }; + }, + 'id', + 'backend' + ), + canSign: computed.alias('signPath.canUpdate'), + + zeroAddressPath: queryRecord( + 'capabilities', + context => { + const { backend } = context.getProperties('backend'); + return { + id: `${backend}/config/zeroaddress`, + }; + }, + 'backend' + ), + canEditZeroAddress: computed.alias('zeroAddressPath.canUpdate'), +}); diff --git a/ui/app/models/secret-cubbyhole.js b/ui/app/models/secret-cubbyhole.js new file mode 100644 index 000000000..fcbf908ca --- /dev/null +++ b/ui/app/models/secret-cubbyhole.js @@ -0,0 +1,3 @@ +import Secret from './secret'; + +export default Secret.extend(); diff --git a/ui/app/models/secret-engine.js b/ui/app/models/secret-engine.js new file mode 100644 index 000000000..3a1a2d25a --- /dev/null +++ b/ui/app/models/secret-engine.js @@ -0,0 +1,83 @@ +import Ember from 'ember'; +import DS from 'ember-data'; +import { queryRecord } from 'ember-computed-query'; +import { fragment } from 'ember-data-model-fragments/attributes'; + +const { attr } = DS; +const { computed } = Ember; + +//identity will be managed separately and the inclusion +//of the system backend is an implementation detail +const LIST_EXCLUDED_BACKENDS = ['system', 'identity']; + +export default DS.Model.extend({ + path: attr('string'), + accessor: attr('string'), + name: attr('string'), + type: attr('string'), + description: attr('string'), + config: attr('object'), + options: fragment('mount-options'), + local: attr('boolean'), + sealWrap: attr('boolean'), + isVersioned: computed.alias('options.versioned'), + + shouldIncludeInList: computed('type', function() { + return !LIST_EXCLUDED_BACKENDS.includes(this.get('type')); + }), + + localDisplay: Ember.computed('local', function() { + return this.get('local') ? 'local' : 'replicated'; + }), + + // ssh specific ones + privateKey: attr('string'), + publicKey: attr('string'), + generateSigningKey: attr('boolean', { + defaultValue: true, + }), + + saveCA(options) { + if (this.get('type') !== 'ssh') { + return; + } + if (options.isDelete) { + this.setProperties({ + privateKey: null, + publicKey: null, + generateSigningKey: false, + }); + } + return this.save({ + adapterOptions: { + options: options, + apiPath: 'config/ca', + attrsToSend: ['privateKey', 'publicKey', 'generateSigningKey'], + }, + }); + }, + + saveZeroAddressConfig() { + return this.save({ + adapterOptions: { + adapterMethod: 'saveZeroAddressConfig', + }, + }); + }, + + zeroAddressPath: queryRecord( + 'capabilities', + context => { + const { id } = context.getProperties('backend', 'id'); + return { + id: `${id}/config/zeroaddress`, + }; + }, + 'id' + ), + canEditZeroAddress: computed.alias('zeroAddressPath.canUpdate'), + + // aws backend attrs + lease: attr('string'), + leaseMax: attr('string'), +}); diff --git a/ui/app/models/secret.js b/ui/app/models/secret.js new file mode 100644 index 000000000..3a1783cca --- /dev/null +++ b/ui/app/models/secret.js @@ -0,0 +1,26 @@ +import Ember from 'ember'; +import DS from 'ember-data'; +import KeyMixin from './key-mixin'; +const { attr } = DS; +const { computed } = Ember; + +export default DS.Model.extend(KeyMixin, { + auth: attr('string'), + lease_duration: attr('number'), + lease_id: attr('string'), + renewable: attr('boolean'), + + secretData: attr('object'), + + dataAsJSONString: computed('secretData', function() { + return JSON.stringify(this.get('secretData'), null, 2); + }), + + isAdvancedFormat: computed('secretData', function() { + const data = this.get('secretData'); + return Object.keys(data).some(key => typeof data[key] !== 'string'); + }), + + helpText: attr('string'), + backend: attr('string'), +}); diff --git a/ui/app/models/ssh-otp-credential.js b/ui/app/models/ssh-otp-credential.js new file mode 100644 index 000000000..13bb2d2ff --- /dev/null +++ b/ui/app/models/ssh-otp-credential.js @@ -0,0 +1,38 @@ +import DS from 'ember-data'; +import Ember from 'ember'; +const { attr } = DS; +const { computed, get } = Ember; +const CREATE_FIELDS = ['username', 'ip']; + +const DISPLAY_FIELDS = ['username', 'ip', 'key', 'keyType', 'port']; +export default DS.Model.extend({ + role: attr('object', { + readOnly: true, + }), + ip: attr('string', { + label: 'IP Address', + }), + username: attr('string'), + key: attr('string'), + keyType: attr('string'), + port: attr('number'), + attrs: computed('key', function() { + let keys = this.get('key') ? DISPLAY_FIELDS.slice(0) : CREATE_FIELDS.slice(0); + get(this.constructor, 'attributes').forEach((meta, name) => { + const index = keys.indexOf(name); + if (index === -1) { + return; + } + keys.replace(index, 1, { + type: meta.type, + name, + options: meta.options, + }); + }); + return keys; + }), + toCreds: computed('key', function() { + // todo: would this be better copied as an SSH command? + return this.get('key'); + }), +}); diff --git a/ui/app/models/ssh-sign.js b/ui/app/models/ssh-sign.js new file mode 100644 index 000000000..66e54dfcb --- /dev/null +++ b/ui/app/models/ssh-sign.js @@ -0,0 +1,61 @@ +import DS from 'ember-data'; +import Ember from 'ember'; +const { attr } = DS; +const { computed, get } = Ember; +const CREATE_FIELDS = [ + 'publicKey', + 'keyId', + 'validPrincipals', + 'certType', + 'criticalOptions', + 'extension', + 'ttl', +]; + +const DISPLAY_FIELDS = ['signedKey', 'leaseId', 'renewable', 'leaseDuration', 'serialNumber']; + +export default DS.Model.extend({ + role: attr('object', { + readOnly: true, + }), + publicKey: attr('string'), + ttl: attr({ + label: 'TTL', + editType: 'ttl', + }), + validPrincipals: attr('string'), + certType: attr('string', { + defaultValue: 'user', + label: 'Certificate Type', + possibleValues: ['user', 'host'], + }), + keyId: attr('string', { + label: 'Key ID', + }), + criticalOptions: attr('object'), + extension: attr('object'), + + leaseId: attr('string', { + label: 'Lease ID', + }), + renewable: attr('boolean'), + leaseDuration: attr('number'), + serialNumber: attr('string'), + signedKey: attr('string'), + + attrs: computed('signedKey', function() { + let keys = this.get('signedKey') ? DISPLAY_FIELDS.slice(0) : CREATE_FIELDS.slice(0); + get(this.constructor, 'attributes').forEach((meta, name) => { + const index = keys.indexOf(name); + if (index === -1) { + return; + } + keys.replace(index, 1, { + type: meta.type, + name, + options: meta.options, + }); + }); + return keys; + }), +}); diff --git a/ui/app/models/test-form-model.js b/ui/app/models/test-form-model.js new file mode 100644 index 000000000..e46d668ef --- /dev/null +++ b/ui/app/models/test-form-model.js @@ -0,0 +1,9 @@ +// this model is just used for integration tests +// + +import AuthMethodModel from './auth-method'; +import { fragment } from 'ember-data-model-fragments/attributes'; + +export default AuthMethodModel.extend({ + otherConfig: fragment('mount-config', { defaultValue: {} }), +}); diff --git a/ui/app/models/transit-key.js b/ui/app/models/transit-key.js new file mode 100644 index 000000000..6895db28b --- /dev/null +++ b/ui/app/models/transit-key.js @@ -0,0 +1,139 @@ +import Ember from 'ember'; +import DS from 'ember-data'; +import clamp from 'vault/utils/clamp'; +import { queryRecord } from 'ember-computed-query'; + +const { attr } = DS; +const { computed, get, set } = Ember; + +const ACTION_VALUES = { + encrypt: 'supportsEncryption', + decrypt: 'supportsDecryption', + datakey: 'supportsEncryption', + rewrap: 'supportsEncryption', + sign: 'supportsSigning', + hmac: true, + verify: true, + export: 'exportable', +}; + +export default DS.Model.extend({ + type: attr('string', { + defaultValue: 'aes256-gcm96', + }), + name: attr('string'), + deletionAllowed: attr('boolean'), + derived: attr('boolean'), + exportable: attr('boolean'), + minDecryptionVersion: attr('number', { + defaultValue: 1, + }), + minEncryptionVersion: attr('number', { + defaultValue: 0, + }), + latestVersion: attr('number'), + keys: attr('object'), + convergentEncryption: attr('boolean'), + convergentEncryptionVersion: attr('number'), + + supportsSigning: attr('boolean'), + supportsEncryption: attr('boolean'), + supportsDecryption: attr('boolean'), + supportsDerivation: attr('boolean'), + + setConvergentEncryption(val) { + if (val === true) { + set(this, 'derived', val); + } + set(this, 'convergentEncryption', val); + }, + + setDerived(val) { + if (val === false) { + set(this, 'convergentEncryption', val); + } + set(this, 'derived', val); + }, + + supportedActions: computed('type', function() { + return Object.keys(ACTION_VALUES).filter(name => { + const isSupported = ACTION_VALUES[name]; + if (typeof isSupported === 'boolean') { + return isSupported; + } + return get(this, isSupported); + }); + }), + + canDelete: computed('deletionAllowed', 'lastLoadTS', function() { + const deleteAttrChanged = Boolean(this.changedAttributes().deletionAllowed); + return get(this, 'deletionAllowed') && deleteAttrChanged === false; + }), + + keyVersions: computed('validKeyVersions', function() { + let maxVersion = Math.max(...get(this, 'validKeyVersions')); + let versions = []; + while (maxVersion > 0) { + versions.unshift(maxVersion); + maxVersion--; + } + return versions; + }), + + encryptionKeyVersions: computed('keyVerisons', 'minDecryptionVersion', 'latestVersion', function() { + const { keyVersions, minDecryptionVersion } = this.getProperties('keyVersions', 'minDecryptionVersion'); + + return keyVersions + .filter(version => { + return version >= minDecryptionVersion; + }) + .reverse(); + }), + + keysForEncryption: computed('minEncryptionVersion', 'latestVersion', function() { + let { minEncryptionVersion, latestVersion } = this.getProperties('minEncryptionVersion', 'latestVersion'); + let minVersion = clamp(minEncryptionVersion - 1, 0, latestVersion); + let versions = []; + while (latestVersion > minVersion) { + versions.push(latestVersion); + latestVersion--; + } + return versions; + }), + + validKeyVersions: computed('keys', function() { + return Object.keys(get(this, 'keys')); + }), + + exportKeyTypes: computed('exportable', 'type', function() { + let types = ['hmac']; + if (this.get('supportsSigning')) { + types.unshift('signing'); + } + if (this.get('supportsEncryption')) { + types.unshift('encryption'); + } + return types; + }), + + backend: attr('string', { + readOnly: true, + }), + + rotatePath: queryRecord( + 'capabilities', + context => { + const { backend, id } = context.getProperties('backend', 'id'); + if (!backend && id) { + return; + } + return { + id: `${backend}/keys/${id}/rotate`, + }; + }, + 'id', + 'backend' + ), + + canRotate: computed.alias('rotatePath.canUpdate'), +}); diff --git a/ui/app/resolver.js b/ui/app/resolver.js new file mode 100644 index 000000000..2fb563d6c --- /dev/null +++ b/ui/app/resolver.js @@ -0,0 +1,3 @@ +import Resolver from 'ember-resolver'; + +export default Resolver; diff --git a/ui/app/router.js b/ui/app/router.js new file mode 100644 index 000000000..f3c65df00 --- /dev/null +++ b/ui/app/router.js @@ -0,0 +1,126 @@ +import Ember from 'ember'; +import config from './config/environment'; + +const Router = Ember.Router.extend({ + location: config.locationType, + rootURL: config.rootURL, +}); + +Router.map(function() { + this.route('vault', { path: '/' }, function() { + this.route('cluster', { path: '/:cluster_name' }, function() { + this.route('auth'); + this.route('init'); + this.route('logout'); + this.route('settings', function() { + this.route('index', { path: '/' }); + this.route('seal'); + this.route('auth', function() { + this.route('index', { path: '/' }); + this.route('enable'); + this.route('configure', { path: '/configure/:method' }, function() { + this.route('index', { path: '/' }); + this.route('section', { path: '/:section_name' }); + }); + }); + this.route('mount-secret-backend'); + this.route('configure-secret-backend', { path: '/secrets/configure/:backend' }, function() { + this.route('index', { path: '/' }); + this.route('section', { path: '/:section_name' }); + }); + }); + this.route('unseal'); + this.route('tools', function() { + this.route('tool', { path: '/:selectedAction' }); + }); + this.route('access', function() { + this.route('methods', { path: '/' }); + this.route('method', { path: '/:path' }, function() { + this.route('index', { path: '/' }); + this.route('section', { path: '/:section_name' }); + }); + this.route('leases', function() { + // lookup + this.route('index', { path: '/' }); + // lookup prefix + // revoke prefix + revoke force + this.route('list-root', { path: '/list/' }); + this.route('list', { path: '/list/*prefix' }); + //renew + revoke + this.route('show', { path: '/show/*lease_id' }); + }); + // the outer identity route handles group and entity items + this.route('identity', { path: '/identity/:item_type' }, function() { + this.route('index', { path: '/' }); + this.route('create'); + this.route('merge'); + this.route('edit', { path: '/edit/:item_id' }); + this.route('show', { path: '/:item_id/:section' }); + this.route('aliases', function() { + this.route('index', { path: '/' }); + this.route('add', { path: '/add/:item_id' }); + this.route('edit', { path: '/edit/:item_alias_id' }); + this.route('show', { path: '/:item_alias_id/:section' }); + }); + }); + }); + this.route('secrets', function() { + this.route('backends', { path: '/' }); + this.route('backend', { path: '/:backend' }, function() { + this.route('index', { path: '/' }); + // because globs / params can't be empty, + // we have to special-case ids of '' with thier own routes + this.route('list-root', { path: '/list/' }); + this.route('create-root', { path: '/create/' }); + this.route('show-root', { path: '/show/' }); + this.route('edit-root', { path: '/edit/' }); + + this.route('list', { path: '/list/*secret' }); + this.route('show', { path: '/show/*secret' }); + this.route('create', { path: '/create/*secret' }); + this.route('edit', { path: '/edit/*secret' }); + + this.route('credentials-root', { path: '/credentials/' }); + this.route('credentials', { path: '/credentials/*secret' }); + + // ssh sign + this.route('sign-root', { path: '/sign/' }); + this.route('sign', { path: '/sign/*secret' }); + // transit-specific routes + this.route('actions-root', { path: '/actions/' }); + this.route('actions', { path: '/actions/*secret' }); + }); + }); + this.route('policies', { path: '/policies/:type' }, function() { + this.route('index', { path: '/' }); + this.route('create', { path: '/create' }); + }); + this.route('policy', { path: '/policy/:type' }, function() { + this.route('show', { path: '/:policy_name' }); + this.route('edit', { path: '/:policy_name/edit' }); + }); + this.route('replication-dr-promote'); + this.route('replication', function() { + this.route('index', { path: '/' }); + this.route('mode', { path: '/:replication_mode' }, function() { + //details + this.route('index', { path: '/' }); + this.route('manage'); + this.route('secondaries', function() { + this.route('add', { path: '/add' }); + this.route('revoke', { path: '/revoke' }); + this.route('config-show', { path: '/config/show/:secondary_id' }); + this.route('config-edit', { path: '/config/edit/:secondary_id' }); + this.route('config-create', { path: '/config/create/:secondary_id' }); + }); + }); + }); + + this.route('response-wrapping'); + this.route('not-found', { path: '/*path' }); + }); + this.route('not-found', { path: '/*path' }); + }); +}); + +export default Router; diff --git a/ui/app/routes/.gitkeep b/ui/app/routes/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/ui/app/routes/application.js b/ui/app/routes/application.js new file mode 100644 index 000000000..d9da2f88a --- /dev/null +++ b/ui/app/routes/application.js @@ -0,0 +1,9 @@ +import Ember from 'ember'; + +export default Ember.Route.extend({ + actions: { + willTransition() { + window.scrollTo(0, 0); + }, + }, +}); diff --git a/ui/app/routes/vault.js b/ui/app/routes/vault.js new file mode 100644 index 000000000..9a2326ec0 --- /dev/null +++ b/ui/app/routes/vault.js @@ -0,0 +1,33 @@ +import Ember from 'ember'; +const SPLASH_DELAY = Ember.testing ? 0 : 300; + +export default Ember.Route.extend({ + version: Ember.inject.service(), + beforeModel() { + return this.get('version').fetchVersion(); + }, + model() { + // hardcode single cluster + const fixture = { + data: { + id: '1', + type: 'cluster', + attributes: { + name: 'vault', + }, + }, + }; + this.store.push(fixture); + return new Ember.RSVP.Promise(resolve => { + Ember.run.later(() => { + resolve(this.store.peekAll('cluster')); + }, SPLASH_DELAY); + }); + }, + + redirect(model, transition) { + if (model.get('length') === 1 && transition.targetName === 'vault.index') { + return this.transitionTo('vault.cluster', model.get('firstObject.name')); + } + }, +}); diff --git a/ui/app/routes/vault/cluster.js b/ui/app/routes/vault/cluster.js new file mode 100644 index 000000000..9b86deb70 --- /dev/null +++ b/ui/app/routes/vault/cluster.js @@ -0,0 +1,72 @@ +import Ember from 'ember'; +import ClusterRoute from 'vault/mixins/cluster-route'; +import ModelBoundaryRoute from 'vault/mixins/model-boundary-route'; + +const POLL_INTERVAL_MS = 10000; +const { inject } = Ember; + +export default Ember.Route.extend(ModelBoundaryRoute, ClusterRoute, { + store: inject.service(), + auth: inject.service(), + currentCluster: Ember.inject.service(), + modelTypes: ['node', 'secret', 'secret-engine'], + + getClusterId(params) { + const { cluster_name } = params; + const cluster = this.modelFor('vault').findBy('name', cluster_name); + return cluster ? cluster.get('id') : null; + }, + + beforeModel() { + const params = this.paramsFor(this.routeName); + const id = this.getClusterId(params); + if (id) { + return this.get('auth').setCluster(id); + } else { + return Ember.RSVP.reject({ httpStatus: 404, message: 'not found', path: params.cluster_name }); + } + }, + + model(params) { + const id = this.getClusterId(params); + return this.get('store').findRecord('cluster', id); + }, + + stopPoll: Ember.on('deactivate', function() { + Ember.run.cancel(this.get('timer')); + }), + + poll() { + // when testing, the polling loop causes promises to never settle so acceptance tests hang + // to get around that, we just disable the poll in tests + return Ember.testing + ? null + : Ember.run.later(() => { + this.controller.get('model').reload().then( + () => { + this.set('timer', this.poll()); + return this.transitionToTargetRoute(); + }, + () => { + this.set('timer', this.poll()); + } + ); + }, POLL_INTERVAL_MS); + }, + + afterModel(model) { + this.get('currentCluster').setCluster(model); + this._super(...arguments); + this.poll(); + return this.transitionToTargetRoute(); + }, + + actions: { + error(e) { + if (e.httpStatus === 503 && e.errors[0] === 'Vault is sealed') { + this.refresh(); + } + return true; + }, + }, +}); diff --git a/ui/app/routes/vault/cluster/access/identity.js b/ui/app/routes/vault/cluster/access/identity.js new file mode 100644 index 000000000..ad1545d19 --- /dev/null +++ b/ui/app/routes/vault/cluster/access/identity.js @@ -0,0 +1,15 @@ +import Ember from 'ember'; + +const MODEL_FROM_PARAM = { + entities: 'entity', + groups: 'group', +}; + +export default Ember.Route.extend({ + model(params) { + let model = MODEL_FROM_PARAM[params.item_type]; + + //TODO 404 behavior; + return model; + }, +}); diff --git a/ui/app/routes/vault/cluster/access/identity/aliases/add.js b/ui/app/routes/vault/cluster/access/identity/aliases/add.js new file mode 100644 index 000000000..5d9af4ba3 --- /dev/null +++ b/ui/app/routes/vault/cluster/access/identity/aliases/add.js @@ -0,0 +1,11 @@ +import Ember from 'ember'; + +export default Ember.Route.extend({ + model(params) { + let itemType = this.modelFor('vault.cluster.access.identity'); + let modelType = `identity/${itemType}-alias`; + return this.store.createRecord(modelType, { + canonicalId: params.item_id, + }); + }, +}); diff --git a/ui/app/routes/vault/cluster/access/identity/aliases/edit.js b/ui/app/routes/vault/cluster/access/identity/aliases/edit.js new file mode 100644 index 000000000..15e9b36dc --- /dev/null +++ b/ui/app/routes/vault/cluster/access/identity/aliases/edit.js @@ -0,0 +1,9 @@ +import Ember from 'ember'; + +export default Ember.Route.extend({ + model(params) { + let itemType = this.modelFor('vault.cluster.access.identity'); + let modelType = `identity/${itemType}-alias`; + return this.store.findRecord(modelType, params.item_alias_id); + }, +}); diff --git a/ui/app/routes/vault/cluster/access/identity/aliases/index.js b/ui/app/routes/vault/cluster/access/identity/aliases/index.js new file mode 100644 index 000000000..027cc1f7d --- /dev/null +++ b/ui/app/routes/vault/cluster/access/identity/aliases/index.js @@ -0,0 +1,36 @@ +import Ember from 'ember'; +import ListRoute from 'vault/mixins/list-route'; + +export default Ember.Route.extend(ListRoute, { + model(params) { + let itemType = this.modelFor('vault.cluster.access.identity'); + let modelType = `identity/${itemType}-alias`; + return this.store + .lazyPaginatedQuery(modelType, { + responsePath: 'data.keys', + page: params.page, + pageFilter: params.pageFilter, + size: 100, + }) + .catch(err => { + if (err.httpStatus === 404) { + return []; + } else { + throw err; + } + }); + }, + setupController(controller) { + this._super(...arguments); + controller.set('identityType', this.modelFor('vault.cluster.access.identity')); + }, + actions: { + willTransition(transition) { + window.scrollTo(0, 0); + if (transition.targetName !== this.routeName) { + this.store.clearAllDatasets(); + } + return true; + }, + }, +}); diff --git a/ui/app/routes/vault/cluster/access/identity/aliases/show.js b/ui/app/routes/vault/cluster/access/identity/aliases/show.js new file mode 100644 index 000000000..21318791e --- /dev/null +++ b/ui/app/routes/vault/cluster/access/identity/aliases/show.js @@ -0,0 +1,30 @@ +import Ember from 'ember'; +import DS from 'ember-data'; +import { TABS } from 'vault/helpers/tabs-for-identity-show'; + +export default Ember.Route.extend({ + model(params) { + let { section } = params; + let itemType = this.modelFor('vault.cluster.access.identity') + '-alias'; + let tabs = TABS[itemType]; + let modelType = `identity/${itemType}`; + if (!tabs.includes(section)) { + const error = new DS.AdapterError(); + Ember.set(error, 'httpStatus', 404); + throw error; + } + // TODO peekRecord here to see if we have the record already + return Ember.RSVP.hash({ + model: this.store.findRecord(modelType, params.item_alias_id), + section, + }); + }, + + setupController(controller, resolvedModel) { + let { model, section } = resolvedModel; + controller.setProperties({ + model, + section, + }); + }, +}); diff --git a/ui/app/routes/vault/cluster/access/identity/create.js b/ui/app/routes/vault/cluster/access/identity/create.js new file mode 100644 index 000000000..a147b9deb --- /dev/null +++ b/ui/app/routes/vault/cluster/access/identity/create.js @@ -0,0 +1,9 @@ +import Ember from 'ember'; + +export default Ember.Route.extend({ + model() { + let itemType = this.modelFor('vault.cluster.access.identity'); + let modelType = `identity/${itemType}`; + return this.store.createRecord(modelType); + }, +}); diff --git a/ui/app/routes/vault/cluster/access/identity/edit.js b/ui/app/routes/vault/cluster/access/identity/edit.js new file mode 100644 index 000000000..df1e5327e --- /dev/null +++ b/ui/app/routes/vault/cluster/access/identity/edit.js @@ -0,0 +1,9 @@ +import Ember from 'ember'; + +export default Ember.Route.extend({ + model(params) { + let itemType = this.modelFor('vault.cluster.access.identity'); + let modelType = `identity/${itemType}`; + return this.store.findRecord(modelType, params.item_id); + }, +}); diff --git a/ui/app/routes/vault/cluster/access/identity/index.js b/ui/app/routes/vault/cluster/access/identity/index.js new file mode 100644 index 000000000..200061ed7 --- /dev/null +++ b/ui/app/routes/vault/cluster/access/identity/index.js @@ -0,0 +1,38 @@ +import Ember from 'ember'; +import ListRoute from 'vault/mixins/list-route'; + +export default Ember.Route.extend(ListRoute, { + model(params) { + let itemType = this.modelFor('vault.cluster.access.identity'); + let modelType = `identity/${itemType}`; + return this.store + .lazyPaginatedQuery(modelType, { + responsePath: 'data.keys', + page: params.page, + pageFilter: params.pageFilter, + size: 100, + }) + .catch(err => { + if (err.httpStatus === 404) { + return []; + } else { + throw err; + } + }); + }, + + setupController(controller) { + this._super(...arguments); + controller.set('identityType', this.modelFor('vault.cluster.access.identity')); + }, + + actions: { + willTransition(transition) { + window.scrollTo(0, 0); + if (transition.targetName !== this.routeName) { + this.store.clearAllDatasets(); + } + return true; + }, + }, +}); diff --git a/ui/app/routes/vault/cluster/access/identity/merge.js b/ui/app/routes/vault/cluster/access/identity/merge.js new file mode 100644 index 000000000..5274646dc --- /dev/null +++ b/ui/app/routes/vault/cluster/access/identity/merge.js @@ -0,0 +1,15 @@ +import Ember from 'ember'; +import UnloadModelRoute from 'vault/mixins/unload-model-route'; + +export default Ember.Route.extend(UnloadModelRoute, { + beforeModel() { + let itemType = this.modelFor('vault.cluster.access.identity'); + if (itemType !== 'entity') { + return this.transitionTo('vault.cluster.access.identity'); + } + }, + model() { + let modelType = `identity/entity-merge`; + return this.store.createRecord(modelType); + }, +}); diff --git a/ui/app/routes/vault/cluster/access/identity/show.js b/ui/app/routes/vault/cluster/access/identity/show.js new file mode 100644 index 000000000..bb4d43277 --- /dev/null +++ b/ui/app/routes/vault/cluster/access/identity/show.js @@ -0,0 +1,37 @@ +import Ember from 'ember'; +import DS from 'ember-data'; +import { TABS } from 'vault/helpers/tabs-for-identity-show'; + +export default Ember.Route.extend({ + model(params) { + let { section } = params; + let itemType = this.modelFor('vault.cluster.access.identity'); + let tabs = TABS[itemType]; + let modelType = `identity/${itemType}`; + if (!tabs.includes(section)) { + const error = new DS.AdapterError(); + Ember.set(error, 'httpStatus', 404); + throw error; + } + // TODO peekRecord here to see if we have the record already + return Ember.RSVP.hash({ + model: this.store.findRecord(modelType, params.item_id), + section, + }); + }, + + afterModel(resolvedModel) { + let { section, model } = resolvedModel; + if (model.get('identityType') === 'group' && model.get('type') === 'internal' && section === 'aliases') { + return this.transitionTo('vault.cluster.access.identity.show', model.id, 'details'); + } + }, + + setupController(controller, resolvedModel) { + let { model, section } = resolvedModel; + controller.setProperties({ + model, + section, + }); + }, +}); diff --git a/ui/app/routes/vault/cluster/access/leases.js b/ui/app/routes/vault/cluster/access/leases.js new file mode 100644 index 000000000..019c1fdd5 --- /dev/null +++ b/ui/app/routes/vault/cluster/access/leases.js @@ -0,0 +1,8 @@ +import Ember from 'ember'; +import ClusterRoute from 'vault/mixins/cluster-route'; + +export default Ember.Route.extend(ClusterRoute, { + model() { + return this.store.findRecord('capabilities', 'sys/leases/lookup/'); + }, +}); diff --git a/ui/app/routes/vault/cluster/access/leases/index.js b/ui/app/routes/vault/cluster/access/leases/index.js new file mode 100644 index 000000000..f423ca2b6 --- /dev/null +++ b/ui/app/routes/vault/cluster/access/leases/index.js @@ -0,0 +1,14 @@ +import Ember from 'ember'; + +export default Ember.Route.extend({ + beforeModel(transition) { + if ( + this.modelFor('vault.cluster.access.leases').get('canList') && + transition.targetName === this.routeName + ) { + return this.replaceWith('vault.cluster.access.leases.list-root'); + } else { + return; + } + }, +}); diff --git a/ui/app/routes/vault/cluster/access/leases/list-root.js b/ui/app/routes/vault/cluster/access/leases/list-root.js new file mode 100644 index 000000000..ea1fedd0a --- /dev/null +++ b/ui/app/routes/vault/cluster/access/leases/list-root.js @@ -0,0 +1 @@ +export { default } from './list'; diff --git a/ui/app/routes/vault/cluster/access/leases/list.js b/ui/app/routes/vault/cluster/access/leases/list.js new file mode 100644 index 000000000..e92bfcc67 --- /dev/null +++ b/ui/app/routes/vault/cluster/access/leases/list.js @@ -0,0 +1,102 @@ +import Ember from 'ember'; + +export default Ember.Route.extend({ + queryParams: { + page: { + refreshModel: true, + }, + pageFilter: { + refreshModel: true, + }, + }, + + templateName: 'vault/cluster/access/leases/list', + + model(params) { + const prefix = params.prefix || ''; + if (this.modelFor('vault.cluster.access.leases').get('canList')) { + return Ember.RSVP.hash({ + leases: this.store + .lazyPaginatedQuery('lease', { + prefix, + responsePath: 'data.keys', + page: params.page, + pageFilter: params.pageFilter, + size: 100, + }) + .then(model => { + this.set('has404', false); + return model; + }) + .catch(err => { + if (err.httpStatus === 404 && prefix === '') { + return []; + } else { + throw err; + } + }), + capabilities: Ember.RSVP.hash({ + revokePrefix: this.store.findRecord('capabilities', `sys/leases/revoke-prefix/${prefix}`), + forceRevokePrefix: this.store.findRecord('capabilities', `sys/leases/revoke-force/${prefix}`), + }), + }); + } + }, + + setupController(controller, model) { + const params = this.paramsFor(this.routeName); + const prefix = params.prefix ? params.prefix : ''; + const has404 = this.get('has404'); + controller.set('hasModel', true); + controller.setProperties({ + model: model.leases, + capabilities: model.capabilities, + baseKey: { id: prefix }, + has404, + }); + if (!has404) { + const pageFilter = params.pageFilter; + let filter; + if (prefix) { + filter = prefix + (pageFilter || ''); + } else if (pageFilter) { + filter = pageFilter; + } + controller.setProperties({ + filter: filter || '', + page: model.leases.get('meta.currentPage'), + }); + } + }, + + resetController(controller, isExiting) { + this._super(...arguments); + if (isExiting) { + controller.set('filter', ''); + } + }, + + actions: { + error(error, transition) { + const { prefix } = this.paramsFor(this.routeName); + + Ember.set(error, 'keyId', prefix); + const hasModel = this.controllerFor(this.routeName).get('hasModel'); + // only swallow the error if we have a previous model + if (hasModel && error.httpStatus === 404) { + this.set('has404', true); + transition.abort(); + } else { + return true; + } + }, + + willTransition(transition) { + window.scrollTo(0, 0); + if (transition.targetName !== this.routeName) { + this.store.clearAllDatasets(); + } + return true; + }, + }, +}); diff --git a/ui/app/routes/vault/cluster/access/leases/show.js b/ui/app/routes/vault/cluster/access/leases/show.js new file mode 100644 index 000000000..4d05b5c38 --- /dev/null +++ b/ui/app/routes/vault/cluster/access/leases/show.js @@ -0,0 +1,54 @@ +import Ember from 'ember'; +import UnloadModelRoute from 'vault/mixins/unload-model-route'; + +import utils from 'vault/lib/key-utils'; + +export default Ember.Route.extend(UnloadModelRoute, { + beforeModel() { + const { lease_id: leaseId } = this.paramsFor(this.routeName); + const parentKey = utils.parentKeyForKey(leaseId); + if (utils.keyIsFolder(leaseId)) { + if (parentKey) { + return this.transitionTo('vault.cluster.access.leases.list', parentKey); + } else { + return this.transitionTo('vault.cluster.access.leases.list-root'); + } + } + }, + + model(params) { + const { lease_id } = params; + return Ember.RSVP.hash({ + lease: this.store.queryRecord('lease', { + lease_id, + }), + capabilities: Ember.RSVP.hash({ + renew: this.store.findRecord('capabilities', 'sys/leases/renew'), + revoke: this.store.findRecord('capabilities', 'sys/leases/revoke'), + leases: this.modelFor('vault.cluster.access.leases'), + }), + }); + }, + + setupController(controller, model) { + this._super(...arguments); + const { lease_id: leaseId } = this.paramsFor(this.routeName); + controller.setProperties({ + model: model.lease, + capabilities: model.capabilities, + baseKey: { id: leaseId }, + }); + }, + + actions: { + error(error) { + const { lease_id } = this.paramsFor(this.routeName); + Ember.set(error, 'keyId', lease_id); + return true; + }, + + refreshModel() { + this.refresh(); + }, + }, +}); diff --git a/ui/app/routes/vault/cluster/access/method.js b/ui/app/routes/vault/cluster/access/method.js new file mode 100644 index 000000000..0291f08ca --- /dev/null +++ b/ui/app/routes/vault/cluster/access/method.js @@ -0,0 +1,17 @@ +import Ember from 'ember'; +import DS from 'ember-data'; + +export default Ember.Route.extend({ + model(params) { + const { path } = params; + return this.store.findAll('auth-method').then(modelArray => { + const model = modelArray.findBy('id', path); + if (!model) { + const error = new DS.AdapterError(); + Ember.set(error, 'httpStatus', 404); + throw error; + } + return model; + }); + }, +}); diff --git a/ui/app/routes/vault/cluster/access/method/index.js b/ui/app/routes/vault/cluster/access/method/index.js new file mode 100644 index 000000000..4bcfbd1f5 --- /dev/null +++ b/ui/app/routes/vault/cluster/access/method/index.js @@ -0,0 +1,7 @@ +import Ember from 'ember'; + +export default Ember.Route.extend({ + beforeModel() { + return this.transitionTo('vault.cluster.access.method.section', 'configuration'); + }, +}); diff --git a/ui/app/routes/vault/cluster/access/method/section.js b/ui/app/routes/vault/cluster/access/method/section.js new file mode 100644 index 000000000..2c94c6153 --- /dev/null +++ b/ui/app/routes/vault/cluster/access/method/section.js @@ -0,0 +1,20 @@ +import Ember from 'ember'; +import DS from 'ember-data'; + +export default Ember.Route.extend({ + model(params) { + const { section_name: section } = params; + if (section !== 'configuration') { + const error = new DS.AdapterError(); + Ember.set(error, 'httpStatus', 404); + throw error; + } + return this.modelFor('vault.cluster.access.method'); + }, + + setupController(controller) { + const { section_name: section } = this.paramsFor(this.routeName); + this._super(...arguments); + controller.set('section', section); + }, +}); diff --git a/ui/app/routes/vault/cluster/access/methods.js b/ui/app/routes/vault/cluster/access/methods.js new file mode 100644 index 000000000..84e53dccf --- /dev/null +++ b/ui/app/routes/vault/cluster/access/methods.js @@ -0,0 +1,16 @@ +import Ember from 'ember'; + +export default Ember.Route.extend({ + queryParams: { + page: { + refreshModel: true, + }, + pageFilter: { + refreshModel: true, + }, + }, + + model() { + return this.store.findAll('auth-method'); + }, +}); diff --git a/ui/app/routes/vault/cluster/auth.js b/ui/app/routes/vault/cluster/auth.js new file mode 100644 index 000000000..875583f71 --- /dev/null +++ b/ui/app/routes/vault/cluster/auth.js @@ -0,0 +1 @@ +export { default } from './cluster-route-base'; diff --git a/ui/app/routes/vault/cluster/cluster-route-base.js b/ui/app/routes/vault/cluster/cluster-route-base.js new file mode 100644 index 000000000..f8e876d83 --- /dev/null +++ b/ui/app/routes/vault/cluster/cluster-route-base.js @@ -0,0 +1,15 @@ +// this is the base route for +// all of the CLUSTER_ROUTES that are states before you can use vault +// +import Ember from 'ember'; +import ClusterRoute from 'vault/mixins/cluster-route'; + +export default Ember.Route.extend(ClusterRoute, { + model() { + return this.modelFor('vault.cluster'); + }, + + resetController(controller) { + controller.reset && controller.reset(); + }, +}); diff --git a/ui/app/routes/vault/cluster/index.js b/ui/app/routes/vault/cluster/index.js new file mode 100644 index 000000000..0b5995841 --- /dev/null +++ b/ui/app/routes/vault/cluster/index.js @@ -0,0 +1,7 @@ +import Ember from 'ember'; + +export default Ember.Route.extend({ + beforeModel() { + return this.transitionTo('vault.cluster.secrets'); + }, +}); diff --git a/ui/app/routes/vault/cluster/init.js b/ui/app/routes/vault/cluster/init.js new file mode 100644 index 000000000..875583f71 --- /dev/null +++ b/ui/app/routes/vault/cluster/init.js @@ -0,0 +1 @@ +export { default } from './cluster-route-base'; diff --git a/ui/app/routes/vault/cluster/logout.js b/ui/app/routes/vault/cluster/logout.js new file mode 100644 index 000000000..0ddc7c110 --- /dev/null +++ b/ui/app/routes/vault/cluster/logout.js @@ -0,0 +1,16 @@ +import Ember from 'ember'; +import ModelBoundaryRoute from 'vault/mixins/model-boundary-route'; + +export default Ember.Route.extend(ModelBoundaryRoute, { + auth: Ember.inject.service(), + flashMessages: Ember.inject.service(), + + modelTypes: ['secret', 'secret-engine'], + + beforeModel() { + this.get('auth').deleteCurrentToken(); + this.clearModelCache(); + this.replaceWith('vault.cluster'); + this.get('flashMessages').clearMessages(); + }, +}); diff --git a/ui/app/routes/vault/cluster/policies.js b/ui/app/routes/vault/cluster/policies.js new file mode 100644 index 000000000..401e74c64 --- /dev/null +++ b/ui/app/routes/vault/cluster/policies.js @@ -0,0 +1,23 @@ +import Ember from 'ember'; +import ClusterRoute from 'vault/mixins/cluster-route'; + +const ALLOWED_TYPES = ['acl', 'egp', 'rgp']; +const { inject } = Ember; + +export default Ember.Route.extend(ClusterRoute, { + version: inject.service(), + + beforeModel() { + return this.get('version').fetchFeatures().then(() => { + return this._super(...arguments); + }); + }, + + model(params) { + let policyType = params.type; + if (!ALLOWED_TYPES.includes(policyType)) { + return this.transitionTo(this.routeName, ALLOWED_TYPES[0]); + } + return {}; + }, +}); diff --git a/ui/app/routes/vault/cluster/policies/create.js b/ui/app/routes/vault/cluster/policies/create.js new file mode 100644 index 000000000..92c438f37 --- /dev/null +++ b/ui/app/routes/vault/cluster/policies/create.js @@ -0,0 +1,25 @@ +import Ember from 'ember'; +import UnloadModelRoute from 'vault/mixins/unload-model-route'; +import UnsavedModelRoute from 'vault/mixins/unsaved-model-route'; + +const { inject } = Ember; +export default Ember.Route.extend(UnloadModelRoute, UnsavedModelRoute, { + version: inject.service(), + model() { + let policyType = this.policyType(); + + if (!this.get('version.features').includes('Sentinel') && policyType !== 'acl') { + return this.transitionTo('vault.cluster.policies', policyType); + } + return this.store.createRecord(`policy/${policyType}`, {}); + }, + + setupController(controller) { + this._super(...arguments); + controller.set('policyType', this.policyType()); + }, + + policyType() { + return this.paramsFor('vault.cluster.policies').type; + }, +}); diff --git a/ui/app/routes/vault/cluster/policies/index.js b/ui/app/routes/vault/cluster/policies/index.js new file mode 100644 index 000000000..50ff37289 --- /dev/null +++ b/ui/app/routes/vault/cluster/policies/index.js @@ -0,0 +1,79 @@ +import Ember from 'ember'; +import ClusterRoute from 'vault/mixins/cluster-route'; + +const { inject } = Ember; + +export default Ember.Route.extend(ClusterRoute, { + version: inject.service(), + queryParams: { + page: { + refreshModel: true, + }, + pageFilter: { + refreshModel: true, + }, + }, + + shouldReturnEmptyModel(policyType, version) { + return policyType !== 'acl' && (version.get('isOSS') || !version.get('features').includes('Sentinel')); + }, + + model(params) { + let policyType = this.policyType(); + if (this.shouldReturnEmptyModel(policyType, this.get('version'))) { + return; + } + return this.store + .lazyPaginatedQuery(`policy/${policyType}`, { + page: params.page, + pageFilter: params.pageFilter, + responsePath: 'data.keys', + size: 100, + }) + .catch(err => { + // acls will never be empty, but sentinel policies can be + if (err.httpStatus === 404 && this.policyType() !== 'acl') { + return []; + } else { + throw err; + } + }); + }, + + setupController(controller, model) { + const params = this.paramsFor(this.routeName); + if (!model) { + controller.setProperties({ + model: null, + policyType: this.policyType(), + }); + return; + } + controller.setProperties({ + model, + filter: params.pageFilter || '', + page: model.get('meta.currentPage') || 1, + policyType: this.policyType(), + }); + }, + + resetController(controller, isExiting) { + this._super(...arguments); + if (isExiting) { + controller.set('filter', ''); + } + }, + actions: { + willTransition(transition) { + window.scrollTo(0, 0); + if (!transition || transition.targetName !== this.routeName) { + this.store.clearAllDatasets(); + } + return true; + }, + }, + + policyType() { + return this.paramsFor('vault.cluster.policies').type; + }, +}); diff --git a/ui/app/routes/vault/cluster/policy.js b/ui/app/routes/vault/cluster/policy.js new file mode 100644 index 000000000..168b05e7d --- /dev/null +++ b/ui/app/routes/vault/cluster/policy.js @@ -0,0 +1,24 @@ +import Ember from 'ember'; +import ClusterRoute from 'vault/mixins/cluster-route'; + +const ALLOWED_TYPES = ['acl', 'egp', 'rgp']; +const { inject } = Ember; + +export default Ember.Route.extend(ClusterRoute, { + version: inject.service(), + beforeModel() { + return this.get('version').fetchFeatures().then(() => { + return this._super(...arguments); + }); + }, + model(params) { + let policyType = params.type; + if (!ALLOWED_TYPES.includes(policyType)) { + return this.transitionTo('vault.cluster.policies', ALLOWED_TYPES[0]); + } + if (!this.get('version.features').includes('Sentinel') && policyType !== 'acl') { + return this.transitionTo('vault.cluster.policies', policyType); + } + return {}; + }, +}); diff --git a/ui/app/routes/vault/cluster/policy/edit.js b/ui/app/routes/vault/cluster/policy/edit.js new file mode 100644 index 000000000..c0743fc75 --- /dev/null +++ b/ui/app/routes/vault/cluster/policy/edit.js @@ -0,0 +1,4 @@ +import UnsavedModelRoute from 'vault/mixins/unsaved-model-route'; +import ShowRoute from './show'; + +export default ShowRoute.extend(UnsavedModelRoute); diff --git a/ui/app/routes/vault/cluster/policy/index.js b/ui/app/routes/vault/cluster/policy/index.js new file mode 100644 index 000000000..2e6e24994 --- /dev/null +++ b/ui/app/routes/vault/cluster/policy/index.js @@ -0,0 +1,7 @@ +import Ember from 'ember'; + +export default Ember.Route.extend({ + beforeModel() { + return this.transitionTo('vault.cluster.policies', 'acl'); + }, +}); diff --git a/ui/app/routes/vault/cluster/policy/show.js b/ui/app/routes/vault/cluster/policy/show.js new file mode 100644 index 000000000..f6a34066e --- /dev/null +++ b/ui/app/routes/vault/cluster/policy/show.js @@ -0,0 +1,32 @@ +import Ember from 'ember'; +import UnloadModelRoute from 'vault/mixins/unload-model-route'; + +export default Ember.Route.extend(UnloadModelRoute, { + beforeModel() { + const params = this.paramsFor(this.routeName); + let policyType = this.policyType(); + if (policyType === 'acl' && params.policy_name === 'root') { + return this.transitionTo('vault.cluster.policies', 'acl'); + } + }, + + model(params) { + let type = this.policyType(); + return Ember.RSVP.hash({ + policy: this.store.findRecord(`policy/${type}`, params.policy_name), + capabilities: this.store.findRecord('capabilities', `sys/policies/${type}/${params.policy_name}`), + }); + }, + + setupController(controller, model) { + controller.setProperties({ + model: model.policy, + capabilities: model.capabilities, + policyType: this.policyType(), + }); + }, + + policyType() { + return this.paramsFor('vault.cluster.policy').type; + }, +}); diff --git a/ui/app/routes/vault/cluster/replication-dr-promote.js b/ui/app/routes/vault/cluster/replication-dr-promote.js new file mode 100644 index 000000000..967e8ab5b --- /dev/null +++ b/ui/app/routes/vault/cluster/replication-dr-promote.js @@ -0,0 +1,10 @@ +import Ember from 'ember'; +import Base from './cluster-route-base'; + +export default Base.extend({ + replicationMode: Ember.inject.service(), + beforeModel() { + this._super(...arguments); + this.get('replicationMode').setMode('dr'); + }, +}); diff --git a/ui/app/routes/vault/cluster/replication.js b/ui/app/routes/vault/cluster/replication.js new file mode 100644 index 000000000..083bca7bb --- /dev/null +++ b/ui/app/routes/vault/cluster/replication.js @@ -0,0 +1,36 @@ +import Ember from 'ember'; +import ClusterRoute from 'vault/mixins/cluster-route'; +const { inject } = Ember; + +export default Ember.Route.extend(ClusterRoute, { + version: inject.service(), + + beforeModel() { + return this.get('version').fetchFeatures().then(() => { + return this._super(...arguments); + }); + }, + + model() { + return this.modelFor('vault.cluster'); + }, + + afterModel(model) { + return Ember.RSVP + .hash({ + canEnablePrimary: this.store + .findRecord('capabilities', 'sys/replication/primary/enable') + .then(c => c.get('canUpdate')), + canEnableSecondary: this.store + .findRecord('capabilities', 'sys/replication/secondary/enable') + .then(c => c.get('canUpdate')), + }) + .then(({ canEnablePrimary, canEnableSecondary }) => { + Ember.setProperties(model, { + canEnablePrimary, + canEnableSecondary, + }); + return model; + }); + }, +}); diff --git a/ui/app/routes/vault/cluster/replication/index.js b/ui/app/routes/vault/cluster/replication/index.js new file mode 100644 index 000000000..ca621413a --- /dev/null +++ b/ui/app/routes/vault/cluster/replication/index.js @@ -0,0 +1,11 @@ +import Ember from 'ember'; + +export default Ember.Route.extend({ + replicationMode: Ember.inject.service(), + beforeModel() { + this.get('replicationMode').setMode(null); + }, + model() { + return this.modelFor('vault.cluster.replication'); + }, +}); diff --git a/ui/app/routes/vault/cluster/replication/mode.js b/ui/app/routes/vault/cluster/replication/mode.js new file mode 100644 index 000000000..810c12c7e --- /dev/null +++ b/ui/app/routes/vault/cluster/replication/mode.js @@ -0,0 +1,25 @@ +import Ember from 'ember'; + +const SUPPORTED_REPLICATION_MODES = ['dr', 'performance']; + +export default Ember.Route.extend({ + replicationMode: Ember.inject.service(), + + beforeModel() { + const replicationMode = this.paramsFor(this.routeName).replication_mode; + if (!SUPPORTED_REPLICATION_MODES.includes(replicationMode)) { + return this.transitionTo('vault.cluster.replication'); + } else { + return this._super(...arguments); + } + }, + + model() { + return this.modelFor('vault.cluster.replication'); + }, + + setReplicationMode: Ember.on('activate', 'enter', function() { + const replicationMode = this.paramsFor(this.routeName).replication_mode; + this.get('replicationMode').setMode(replicationMode); + }), +}); diff --git a/ui/app/routes/vault/cluster/replication/mode/index.js b/ui/app/routes/vault/cluster/replication/mode/index.js new file mode 100644 index 000000000..72dabdd5c --- /dev/null +++ b/ui/app/routes/vault/cluster/replication/mode/index.js @@ -0,0 +1,12 @@ +import Ember from 'ember'; + +export default Ember.Route.extend({ + replicationMode: Ember.inject.service(), + beforeModel() { + const replicationMode = this.paramsFor('vault.cluster.replication.mode').replication_mode; + this.get('replicationMode').setMode(replicationMode); + }, + model() { + return this.modelFor('vault.cluster.replication.mode'); + }, +}); diff --git a/ui/app/routes/vault/cluster/replication/mode/manage.js b/ui/app/routes/vault/cluster/replication/mode/manage.js new file mode 100644 index 000000000..83e48e3fc --- /dev/null +++ b/ui/app/routes/vault/cluster/replication/mode/manage.js @@ -0,0 +1,46 @@ +import Ember from 'ember'; +import { replicationActionForMode } from 'vault/helpers/replication-action-for-mode'; + +const pathForAction = (action, replicationMode, clusterMode) => { + let path; + if (action === 'reindex' || action === 'recover') { + path = `sys/replication/${action}`; + } else { + path = `sys/replication/${replicationMode}/${clusterMode}/${action}`; + } + return path; +}; + +export default Ember.Route.extend({ + store: Ember.inject.service(), + model() { + const store = this.get('store'); + const model = this.modelFor('vault.cluster.replication.mode'); + + const replicationMode = this.paramsFor('vault.cluster.replication.mode').replication_mode; + const clusterMode = model.get(replicationMode).get('modeForUrl'); + const actions = replicationActionForMode([replicationMode, clusterMode]); + return Ember.RSVP + .all( + actions.map(action => { + return store.findRecord('capabilities', pathForAction(action)).then(capability => { + model.set(`can${Ember.String.camelize(action)}`, capability.get('canUpdate')); + }); + }) + ) + .then(() => { + return model; + }); + }, + + beforeModel() { + const model = this.modelFor('vault.cluster.replication.mode'); + const replicationMode = this.paramsFor('vault.cluster.replication.mode').replication_mode; + if ( + model.get(replicationMode).get('replicationDisabled') || + model.get(replicationMode).get('replicationUnsupported') + ) { + return this.transitionTo('vault.cluster.replication.mode', replicationMode); + } + }, +}); diff --git a/ui/app/routes/vault/cluster/replication/mode/secondaries.js b/ui/app/routes/vault/cluster/replication/mode/secondaries.js new file mode 100644 index 000000000..30b1d2e4b --- /dev/null +++ b/ui/app/routes/vault/cluster/replication/mode/secondaries.js @@ -0,0 +1,35 @@ +import Ember from 'ember'; + +export default Ember.Route.extend({ + model() { + const replicationMode = this.paramsFor('vault.cluster.replication.mode').replication_mode; + + return Ember.RSVP + .hash({ + cluster: this.modelFor('vault.cluster.replication.mode'), + canAddSecondary: this.store + .findRecord('capabilities', `sys/replication/${replicationMode}/primary/secondary-token`) + .then(c => c.get('canUpdate')), + canRevokeSecondary: this.store + .findRecord('capabilities', `sys/replication/${replicationMode}/primary/revoke-secondary`) + .then(c => c.get('canUpdate')), + }) + .then(({ cluster, canAddSecondary, canRevokeSecondary }) => { + Ember.setProperties(cluster, { + canRevokeSecondary, + canAddSecondary, + }); + return cluster; + }); + }, + afterModel(model) { + const replicationMode = this.paramsFor('vault.cluster.replication.mode').replication_mode; + if ( + !model.get(`${replicationMode}.isPrimary`) || + model.get(`${replicationMode}.replicationDisabled`) || + model.get(`${replicationMode}.replicationUnsupported`) + ) { + return this.transitionTo('vault.cluster.replication.mode', replicationMode); + } + }, +}); diff --git a/ui/app/routes/vault/cluster/replication/mode/secondaries/add.js b/ui/app/routes/vault/cluster/replication/mode/secondaries/add.js new file mode 100644 index 000000000..2436e0da0 --- /dev/null +++ b/ui/app/routes/vault/cluster/replication/mode/secondaries/add.js @@ -0,0 +1,27 @@ +import Ember from 'ember'; +import Base from '../../replication-base'; + +export default Base.extend({ + model() { + return Ember.RSVP.hash({ + cluster: this.modelFor('vault.cluster.replication.mode.secondaries'), + mounts: this.fetchMounts(), + }); + }, + + redirect(model) { + const replicationMode = this.get('replicationMode'); + if (!model.cluster.get(`${replicationMode}.isPrimary`) || !model.cluster.get('canAddSecondary')) { + return this.transitionTo('vault.cluster.replication.mode', model.cluster.get('name'), replicationMode); + } + }, + + setupController(controller, model) { + controller.set('model', model.cluster); + controller.set('mounts', model.mounts); + }, + + resetController(controller) { + controller.reset(); + }, +}); diff --git a/ui/app/routes/vault/cluster/replication/mode/secondaries/config-create.js b/ui/app/routes/vault/cluster/replication/mode/secondaries/config-create.js new file mode 100644 index 000000000..67dfcf505 --- /dev/null +++ b/ui/app/routes/vault/cluster/replication/mode/secondaries/config-create.js @@ -0,0 +1,54 @@ +import Ember from 'ember'; +import Base from '../../replication-base'; + +export default Base.extend({ + flashMessages: Ember.inject.service(), + + modelPath: 'model.config', + + findOrCreate(id) { + const flash = this.get('flashMessages'); + return this.store + .findRecord('mount-filter-config', id) + .then(() => { + // if we find a record, transition to the edit view + return this.transitionTo('vault.cluster.replication.mode.secondaries.config-edit', id) + .followRedirects() + .then(() => { + flash.info( + `${id} already had a mount filter config, so we loaded the config edit screen for you.` + ); + }); + }) + .catch(e => { + if (e.httpStatus === 404) { + return this.store.createRecord('mount-filter-config', { + id, + }); + } else { + throw e; + } + }); + }, + + redirect(model) { + const cluster = model.cluster; + const replicationMode = this.get('replicationMode'); + if ( + !this.get('version.hasPerfReplication') || + replicationMode !== 'performance' || + !cluster.get(`${replicationMode}.isPrimary`) || + !cluster.get('canAddSecondary') + ) { + return this.transitionTo('vault.cluster.replication.mode', cluster.get('name'), replicationMode); + } + }, + + model(params) { + return Ember.RSVP.hash({ + cluster: this.modelFor('vault.cluster.replication.mode'), + config: this.findOrCreate(params.secondary_id), + mounts: this.fetchMounts(), + }); + }, +}); diff --git a/ui/app/routes/vault/cluster/replication/mode/secondaries/config-edit.js b/ui/app/routes/vault/cluster/replication/mode/secondaries/config-edit.js new file mode 100644 index 000000000..4c82b19e9 --- /dev/null +++ b/ui/app/routes/vault/cluster/replication/mode/secondaries/config-edit.js @@ -0,0 +1,27 @@ +import Ember from 'ember'; +import Base from '../../replication-base'; + +export default Base.extend({ + modelPath: 'model.config', + + model(params) { + return Ember.RSVP.hash({ + cluster: this.modelFor('vault.cluster.replication.mode.secondaries'), + config: this.store.findRecord('mount-filter-config', params.secondary_id), + mounts: this.fetchMounts(), + }); + }, + + redirect(model) { + const cluster = model.cluster; + const replicationMode = this.get('replicationMode'); + if ( + !this.get('version.hasPerfReplication') || + replicationMode !== 'performance' || + !cluster.get(`${replicationMode}.isPrimary`) || + !cluster.get('canAddSecondary') + ) { + return this.transitionTo('vault.cluster.replication.mode', cluster.get('name'), replicationMode); + } + }, +}); diff --git a/ui/app/routes/vault/cluster/replication/mode/secondaries/config-show.js b/ui/app/routes/vault/cluster/replication/mode/secondaries/config-show.js new file mode 100644 index 000000000..44d164ff8 --- /dev/null +++ b/ui/app/routes/vault/cluster/replication/mode/secondaries/config-show.js @@ -0,0 +1,32 @@ +import Ember from 'ember'; +import Base from '../../replication-base'; + +export default Base.extend({ + modelPath: 'model.config', + + model(params) { + const id = params.secondary_id; + return Ember.RSVP.hash({ + cluster: this.modelFor('vault.cluster.replication'), + config: this.store.findRecord('mount-filter-config', id).catch(e => { + if (e.httpStatus === 404) { + // return an empty obj to let them nav to create + return Ember.RSVP.resolve({ id }); + } else { + throw e; + } + }), + }); + }, + redirect(model) { + const cluster = model.cluster; + const replicationMode = this.paramsFor('vault.cluster.replication.mode').replication_mode; + if ( + !this.get('version.hasPerfReplication') || + replicationMode !== 'performance' || + !cluster.get(`${replicationMode}.isPrimary`) + ) { + return this.transitionTo('vault.cluster.replication.mode', cluster.get('name'), replicationMode); + } + }, +}); diff --git a/ui/app/routes/vault/cluster/replication/mode/secondaries/revoke.js b/ui/app/routes/vault/cluster/replication/mode/secondaries/revoke.js new file mode 100644 index 000000000..0ceaf8ea0 --- /dev/null +++ b/ui/app/routes/vault/cluster/replication/mode/secondaries/revoke.js @@ -0,0 +1,18 @@ +import Base from '../../replication-base'; + +export default Base.extend({ + model() { + return this.modelFor('vault.cluster.replication.secondaries'); + }, + + redirect(model) { + const replicationMode = this.get('replicationMode'); + if (!model.get(`${replicationMode}.isPrimary`) || !model.get('canRevokeSecondary')) { + return this.transitionTo('vault.cluster.replication', model.get('name')); + } + }, + + resetController(controller) { + controller.reset(); + }, +}); diff --git a/ui/app/routes/vault/cluster/replication/replication-base.js b/ui/app/routes/vault/cluster/replication/replication-base.js new file mode 100644 index 000000000..9749412ce --- /dev/null +++ b/ui/app/routes/vault/cluster/replication/replication-base.js @@ -0,0 +1,20 @@ +import Ember from 'ember'; +import UnloadModelRouteMixin from 'vault/mixins/unload-model-route'; + +export default Ember.Route.extend(UnloadModelRouteMixin, { + modelPath: 'model.config', + fetchMounts() { + return Ember.RSVP + .hash({ + mounts: this.store.findAll('secret-engine'), + auth: this.store.findAll('auth-method'), + }) + .then(({ mounts, auth }) => { + return Ember.RSVP.resolve(mounts.toArray().concat(auth.toArray())); + }); + }, + + version: Ember.inject.service(), + rm: Ember.inject.service('replication-mode'), + replicationMode: Ember.computed.alias('rm.mode'), +}); diff --git a/ui/app/routes/vault/cluster/secrets.js b/ui/app/routes/vault/cluster/secrets.js new file mode 100644 index 000000000..d86c74ead --- /dev/null +++ b/ui/app/routes/vault/cluster/secrets.js @@ -0,0 +1,8 @@ +import Ember from 'ember'; +import ClusterRoute from 'vault/mixins/cluster-route'; + +export default Ember.Route.extend(ClusterRoute, { + model() { + return this.store.query('secret-engine', {}); + }, +}); diff --git a/ui/app/routes/vault/cluster/secrets/backend.js b/ui/app/routes/vault/cluster/secrets/backend.js new file mode 100644 index 000000000..58b73d607 --- /dev/null +++ b/ui/app/routes/vault/cluster/secrets/backend.js @@ -0,0 +1,20 @@ +import Ember from 'ember'; +const { inject } = Ember; +export default Ember.Route.extend({ + flashMessages: inject.service(), + beforeModel(transition) { + const target = transition.targetName; + const { backend } = this.paramsFor(this.routeName); + const backendModel = this.store.peekRecord('secret-engine', backend); + const type = backendModel && backendModel.get('type'); + if (type === 'kv' && backendModel.get('isVersioned')) { + this.get('flashMessages').stickyInfo( + `"${backend}" is a versioned kv secrets engine. The Vault UI does not currently support the additional versioning features. All actions taken through the UI in this engine will operate on the most recent version of a secret.` + ); + } + + if (target === this.routeName) { + return this.replaceWith('vault.cluster.secrets.backend.list-root', backend); + } + }, +}); diff --git a/ui/app/routes/vault/cluster/secrets/backend/actions.js b/ui/app/routes/vault/cluster/secrets/backend/actions.js new file mode 100644 index 000000000..a33e06d93 --- /dev/null +++ b/ui/app/routes/vault/cluster/secrets/backend/actions.js @@ -0,0 +1,30 @@ +import EditBase from './secret-edit'; +import utils from 'vault/lib/key-utils'; + +export default EditBase.extend({ + queryParams: { + selectedAction: { + replace: true, + }, + }, + + templateName: 'vault/cluster/secrets/backend/transitActionsLayout', + + beforeModel() { + const { secret } = this.paramsFor(this.routeName); + const parentKey = utils.parentKeyForKey(secret); + const { backend } = this.paramsFor('vault.cluster.secrets.backend'); + if (this.backendType(backend) !== 'transit') { + if (parentKey) { + return this.transitionTo('vault.cluster.secrets.backend.show', parentKey); + } else { + return this.transitionTo('vault.cluster.secrets.backend.show-root'); + } + } + }, + setupController(controller, model) { + this._super(...arguments); + const { selectedAction } = this.paramsFor(this.routeName); + controller.set('selectedAction', selectedAction || model.secret.get('supportedActions.firstObject')); + }, +}); diff --git a/ui/app/routes/vault/cluster/secrets/backend/create-root.js b/ui/app/routes/vault/cluster/secrets/backend/create-root.js new file mode 100644 index 000000000..28d5c8ccc --- /dev/null +++ b/ui/app/routes/vault/cluster/secrets/backend/create-root.js @@ -0,0 +1 @@ +export { default } from './create'; diff --git a/ui/app/routes/vault/cluster/secrets/backend/create.js b/ui/app/routes/vault/cluster/secrets/backend/create.js new file mode 100644 index 000000000..a3bf26844 --- /dev/null +++ b/ui/app/routes/vault/cluster/secrets/backend/create.js @@ -0,0 +1,52 @@ +import Ember from 'ember'; +import EditBase from './secret-edit'; +import KeyMixin from 'vault/models/key-mixin'; + +var SecretProxy = Ember.Object.extend(KeyMixin, { + store: null, + + toModel() { + return this.getProperties('id', 'secretData', 'backend'); + }, + + createRecord(backend) { + let modelType = 'secret'; + if (backend === 'cubbyhole') { + modelType = modelType + '-cubbyhole'; + } + return this.store.createRecord(modelType, this.toModel()); + }, +}); + +export default EditBase.extend({ + createModel(transition, parentKey) { + const { backend } = this.paramsFor('vault.cluster.secrets.backend'); + const modelType = this.modelType(backend); + if (modelType === 'role-ssh') { + return this.store.createRecord(modelType, { keyType: 'ca' }); + } + if (modelType !== 'secret' && modelType !== 'secret-cubbyhole') { + return this.store.createRecord(modelType); + } + const key = transition.queryParams.initialKey || ''; + const model = SecretProxy.create({ + initialParentKey: parentKey, + store: this.store, + }); + + if (key) { + // have to set this after so that it will be + // computed properly in the template (it's dependent on `initialParentKey`) + model.set('keyWithoutParent', key); + } + return model; + }, + + model(params, transition) { + const parentKey = params.secret ? params.secret : ''; + return Ember.RSVP.hash({ + secret: this.createModel(transition, parentKey), + capabilities: this.capabilities(parentKey), + }); + }, +}); diff --git a/ui/app/routes/vault/cluster/secrets/backend/credentials-root.js b/ui/app/routes/vault/cluster/secrets/backend/credentials-root.js new file mode 100644 index 000000000..cbf926c3a --- /dev/null +++ b/ui/app/routes/vault/cluster/secrets/backend/credentials-root.js @@ -0,0 +1 @@ +export { default } from './credentials'; diff --git a/ui/app/routes/vault/cluster/secrets/backend/credentials.js b/ui/app/routes/vault/cluster/secrets/backend/credentials.js new file mode 100644 index 000000000..034117161 --- /dev/null +++ b/ui/app/routes/vault/cluster/secrets/backend/credentials.js @@ -0,0 +1,52 @@ +import Ember from 'ember'; +import UnloadModel from 'vault/mixins/unload-model-route'; + +const SUPPORTED_DYNAMIC_BACKENDS = ['ssh', 'aws', 'pki']; + +export default Ember.Route.extend(UnloadModel, { + templateName: 'vault/cluster/secrets/backend/credentials', + + backendModel() { + const backend = this.paramsFor('vault.cluster.secrets.backend').backend; + return this.store.peekRecord('secret-engine', backend); + }, + + pathQuery(role, backend) { + const type = this.backendModel().get('type'); + if (type === 'pki') { + return `${backend}/issue/${role}`; + } + return `${backend}/creds/${role}`; + }, + + model(params) { + const role = params.secret; + const backendModel = this.backendModel(); + const backend = backendModel.get('id'); + + if (!SUPPORTED_DYNAMIC_BACKENDS.includes(backendModel.get('type'))) { + return this.transitionTo('vault.cluster.secrets.backend.list-root', backend); + } + return this.store + .queryRecord('capabilities', { id: this.pathQuery(role, backend) }) + .then(capabilities => { + if (!capabilities.get('canUpdate')) { + return this.transitionTo('vault.cluster.secrets.backend.list-root', backend); + } + return Ember.RSVP.resolve({ + backend, + id: role, + name: role, + }); + }); + }, + + setupController(controller) { + this._super(...arguments); + controller.set('backend', this.backendModel()); + }, + + resetController(controller) { + controller.reset(); + }, +}); diff --git a/ui/app/routes/vault/cluster/secrets/backend/edit-root.js b/ui/app/routes/vault/cluster/secrets/backend/edit-root.js new file mode 100644 index 000000000..58cfd6a1c --- /dev/null +++ b/ui/app/routes/vault/cluster/secrets/backend/edit-root.js @@ -0,0 +1 @@ +export { default } from './edit'; diff --git a/ui/app/routes/vault/cluster/secrets/backend/edit.js b/ui/app/routes/vault/cluster/secrets/backend/edit.js new file mode 100644 index 000000000..69b9f2e0b --- /dev/null +++ b/ui/app/routes/vault/cluster/secrets/backend/edit.js @@ -0,0 +1,3 @@ +import EditBase from './secret-edit'; + +export default EditBase.extend(); diff --git a/ui/app/routes/vault/cluster/secrets/backend/index.js b/ui/app/routes/vault/cluster/secrets/backend/index.js new file mode 100644 index 000000000..67782f890 --- /dev/null +++ b/ui/app/routes/vault/cluster/secrets/backend/index.js @@ -0,0 +1,7 @@ +import Ember from 'ember'; + +export default Ember.Route.extend({ + beforeModel() { + return this.replaceWith('vault.cluster.secrets.backend.list-root'); + }, +}); diff --git a/ui/app/routes/vault/cluster/secrets/backend/list-root.js b/ui/app/routes/vault/cluster/secrets/backend/list-root.js new file mode 100644 index 000000000..ea1fedd0a --- /dev/null +++ b/ui/app/routes/vault/cluster/secrets/backend/list-root.js @@ -0,0 +1 @@ +export { default } from './list'; diff --git a/ui/app/routes/vault/cluster/secrets/backend/list.js b/ui/app/routes/vault/cluster/secrets/backend/list.js new file mode 100644 index 000000000..78ee1a3da --- /dev/null +++ b/ui/app/routes/vault/cluster/secrets/backend/list.js @@ -0,0 +1,168 @@ +import Ember from 'ember'; +import { supportedSecretBackends } from 'vault/helpers/supported-secret-backends'; + +const SUPPORTED_BACKENDS = supportedSecretBackends(); + +export default Ember.Route.extend({ + queryParams: { + page: { + refreshModel: true, + }, + pageFilter: { + refreshModel: true, + }, + tab: { + refreshModel: true, + }, + }, + + templateName: 'vault/cluster/secrets/backend/list', + + beforeModel() { + const { backend } = this.paramsFor('vault.cluster.secrets.backend'); + const backendModel = this.store.peekRecord('secret-engine', backend); + const type = backendModel && backendModel.get('type'); + if (!type || !SUPPORTED_BACKENDS.includes(type)) { + return this.transitionTo('vault.cluster.secrets'); + } + }, + + capabilities(secret) { + const { backend } = this.paramsFor('vault.cluster.secrets.backend'); + const path = backend + '/' + secret; + return this.store.findRecord('capabilities', path); + }, + + getModelType(backend, tab) { + const types = { + transit: 'transit-key', + ssh: 'role-ssh', + aws: 'role-aws', + cubbyhole: 'secret-cubbyhole', + pki: tab === 'certs' ? 'pki-certificate' : 'role-pki', + }; + const backendModel = this.store.peekRecord('secret-engine', backend); + return types[backendModel.get('type')] || 'secret'; + }, + + model(params) { + const secret = params.secret ? params.secret : ''; + const { backend } = this.paramsFor('vault.cluster.secrets.backend'); + const backends = this.modelFor('vault.cluster.secrets').mapBy('id'); + return Ember.RSVP.hash({ + secrets: this.store + .lazyPaginatedQuery(this.getModelType(backend, params.tab), { + id: secret, + backend, + responsePath: 'data.keys', + page: params.page, + pageFilter: params.pageFilter, + size: 100, + }) + .then(model => { + this.set('has404', false); + return model; + }) + .catch(err => { + if (backends.includes(backend) && err.httpStatus === 404 && secret === '') { + return []; + } else { + throw err; + } + }), + capabilities: this.capabilities(secret), + }); + }, + + afterModel(model) { + const { tab } = this.paramsFor(this.routeName); + const { backend } = this.paramsFor('vault.cluster.secrets.backend'); + if (!tab || tab !== 'certs') { + return; + } + return Ember.RSVP + .all( + // these ids are treated specially by vault's api, but it's also + // possible that there is no certificate for them in order to know, + // we fetch them specifically on the list page, and then unload the + // records if there is no `certificate` attribute on the resultant model + ['ca', 'crl', 'ca_chain'].map(id => this.store.queryRecord('pki-certificate', { id, backend })) + ) + .then( + results => { + results.rejectBy('certificate').forEach(record => record.unloadRecord()); + return model; + }, + () => { + return model; + } + ); + }, + + setupController(controller, model) { + const secretParams = this.paramsFor(this.routeName); + const secret = secretParams.secret ? secretParams.secret : ''; + const { backend } = this.paramsFor('vault.cluster.secrets.backend'); + const backendModel = this.store.peekRecord('secret-engine', backend); + const has404 = this.get('has404'); + controller.set('hasModel', true); + controller.setProperties({ + model: model.secrets, + capabilities: model.capabilities, + baseKey: { id: secret }, + has404, + backend, + backendModel, + backendType: backendModel.get('type'), + }); + if (!has404) { + const pageFilter = secretParams.pageFilter; + let filter; + if (secret) { + filter = secret + (pageFilter || ''); + } else if (pageFilter) { + filter = pageFilter; + } + controller.setProperties({ + filter: filter || '', + page: model.secrets.get('meta.currentPage') || 1, + }); + } + }, + + resetController(controller, isExiting) { + this._super(...arguments); + if (isExiting) { + controller.set('filter', ''); + } + }, + + actions: { + error(error, transition) { + const { secret } = this.paramsFor(this.routeName); + const { backend } = this.paramsFor('vault.cluster.secrets.backend'); + const backends = this.modelFor('vault.cluster.secrets').mapBy('id'); + + Ember.set(error, 'secret', secret); + Ember.set(error, 'isRoot', true); + Ember.set(error, 'hasBackend', backends.includes(backend)); + Ember.set(error, 'backend', backend); + const hasModel = this.controllerFor(this.routeName).get('hasModel'); + // only swallow the error if we have a previous model + if (hasModel && error.httpStatus === 404) { + this.set('has404', true); + transition.abort(); + } else { + return true; + } + }, + + willTransition(transition) { + window.scrollTo(0, 0); + if (transition.targetName !== this.routeName) { + this.store.clearAllDatasets(); + } + return true; + }, + }, +}); diff --git a/ui/app/routes/vault/cluster/secrets/backend/secret-edit.js b/ui/app/routes/vault/cluster/secrets/backend/secret-edit.js new file mode 100644 index 000000000..2583125b2 --- /dev/null +++ b/ui/app/routes/vault/cluster/secrets/backend/secret-edit.js @@ -0,0 +1,140 @@ +import Ember from 'ember'; +import utils from 'vault/lib/key-utils'; +import UnloadModelRoute from 'vault/mixins/unload-model-route'; + +export default Ember.Route.extend(UnloadModelRoute, { + capabilities(secret) { + const { backend } = this.paramsFor('vault.cluster.secrets.backend'); + let path; + if (backend === 'transit') { + path = backend + '/keys/' + secret; + } else if (backend === 'ssh' || backend === 'aws') { + path = backend + '/roles/' + secret; + } else { + path = backend + '/' + secret; + } + return this.store.findRecord('capabilities', path); + }, + + backendType(path) { + return this.store.peekRecord('secret-engine', path).get('type'); + }, + + templateName: 'vault/cluster/secrets/backend/secretEditLayout', + + beforeModel() { + // currently there is no recursive delete for folders in vault, so there's no need to 'edit folders' + // perhaps in the future we could recurse _for_ users, but for now, just kick them + // back to the list + const { secret } = this.paramsFor(this.routeName); + const parentKey = utils.parentKeyForKey(secret); + const mode = this.routeName.split('.').pop(); + if (mode === 'edit' && utils.keyIsFolder(secret)) { + if (parentKey) { + return this.transitionTo('vault.cluster.secrets.backend.list', parentKey); + } else { + return this.transitionTo('vault.cluster.secrets.backend.list-root'); + } + } + }, + + modelType(backend, secret) { + const models = { + transit: 'transit-key', + ssh: 'role-ssh', + aws: 'role-aws', + cubbyhole: 'secret-cubbyhole', + pki: secret && secret.startsWith('cert/') ? 'pki-certificate' : 'role-pki', + }; + return models[this.backendType(backend)] || 'secret'; + }, + + model(params) { + let { secret } = params; + const { backend } = this.paramsFor('vault.cluster.secrets.backend'); + const modelType = this.modelType(backend, secret); + + if (!secret) { + secret = '\u0020'; + } + if (modelType === 'pki-certificate') { + secret = secret.replace('cert/', ''); + } + return Ember.RSVP.hash({ + secret: this.store.queryRecord(modelType, { id: secret, backend }), + capabilities: this.capabilities(secret), + }); + }, + + setupController(controller, model) { + this._super(...arguments); + const { secret } = this.paramsFor(this.routeName); + const { backend } = this.paramsFor('vault.cluster.secrets.backend'); + const preferAdvancedEdit = + this.controllerFor('vault.cluster.secrets.backend').get('preferAdvancedEdit') || false; + const backendType = this.backendType(backend); + model.secret.setProperties({ backend }); + controller.setProperties({ + model: model.secret, + capabilities: model.capabilities, + baseKey: { id: secret }, + // mode will be 'show', 'edit', 'create' + mode: this.routeName.split('.').pop().replace('-root', ''), + backend, + preferAdvancedEdit, + backendType, + }); + }, + + resetController(controller) { + if (controller.reset && typeof controller.reset === 'function') { + controller.reset(); + } + }, + + actions: { + error(error) { + const { secret } = this.paramsFor(this.routeName); + const { backend } = this.paramsFor('vault.cluster.secrets.backend'); + const backends = this.modelFor('vault.cluster.secrets').mapBy('id'); + Ember.set(error, 'keyId', backend + '/' + secret); + Ember.set(error, 'backend', backend); + Ember.set(error, 'hasBackend', backends.includes(backend)); + return true; + }, + + refreshModel() { + this.refresh(); + }, + + willTransition(transition) { + const mode = this.routeName.split('.').pop(); + if (mode === 'show') { + return transition; + } + if (this.get('hasChanges')) { + if ( + window.confirm( + 'You have unsaved changes. Navigating away will discard these changes. Are you sure you want to discard your changes?' + ) + ) { + this.unloadModel(); + this.set('hasChanges', false); + return transition; + } else { + transition.abort(); + return false; + } + } + }, + + hasDataChanges(hasChanges) { + this.set('hasChanges', hasChanges); + }, + + toggleAdvancedEdit(bool) { + this.controller.set('preferAdvancedEdit', bool); + this.controllerFor('vault.cluster.secrets.backend').set('preferAdvancedEdit', bool); + }, + }, +}); diff --git a/ui/app/routes/vault/cluster/secrets/backend/show-root.js b/ui/app/routes/vault/cluster/secrets/backend/show-root.js new file mode 100644 index 000000000..98bd0c1a0 --- /dev/null +++ b/ui/app/routes/vault/cluster/secrets/backend/show-root.js @@ -0,0 +1 @@ +export { default } from './show'; diff --git a/ui/app/routes/vault/cluster/secrets/backend/show.js b/ui/app/routes/vault/cluster/secrets/backend/show.js new file mode 100644 index 000000000..69b9f2e0b --- /dev/null +++ b/ui/app/routes/vault/cluster/secrets/backend/show.js @@ -0,0 +1,3 @@ +import EditBase from './secret-edit'; + +export default EditBase.extend(); diff --git a/ui/app/routes/vault/cluster/secrets/backend/sign-root.js b/ui/app/routes/vault/cluster/secrets/backend/sign-root.js new file mode 100644 index 000000000..d034c1595 --- /dev/null +++ b/ui/app/routes/vault/cluster/secrets/backend/sign-root.js @@ -0,0 +1 @@ +export { default } from './sign'; diff --git a/ui/app/routes/vault/cluster/secrets/backend/sign.js b/ui/app/routes/vault/cluster/secrets/backend/sign.js new file mode 100644 index 000000000..b4329d527 --- /dev/null +++ b/ui/app/routes/vault/cluster/secrets/backend/sign.js @@ -0,0 +1,45 @@ +import Ember from 'ember'; +import UnloadModel from 'vault/mixins/unload-model-route'; + +export default Ember.Route.extend(UnloadModel, { + templateName: 'vault/cluster/secrets/backend/sign', + + backendModel() { + const backend = this.paramsFor('vault.cluster.secrets.backend').backend; + return this.store.peekRecord('secret-engine', backend); + }, + + pathQuery(role, backend) { + return { + id: `${backend}/sign/${role}`, + }; + }, + + model(params) { + const role = params.secret; + const backendModel = this.backendModel(); + const backend = backendModel.get('id'); + + if (backendModel.get('type') !== 'ssh') { + return this.transitionTo('vault.cluster.secrets.backend.list-root', backend); + } + return this.store.queryRecord('capabilities', this.pathQuery(role, backend)).then(capabilities => { + if (!capabilities.get('canUpdate')) { + return this.transitionTo('vault.cluster.secrets.backend.list-root', backend); + } + return this.store.createRecord('ssh-sign', { + role: { + backend, + id: role, + name: role, + }, + id: `${backend}-${role}`, + }); + }); + }, + + setupController(controller) { + this._super(...arguments); + controller.set('backend', this.backendModel()); + }, +}); diff --git a/ui/app/routes/vault/cluster/settings.js b/ui/app/routes/vault/cluster/settings.js new file mode 100644 index 000000000..212fac094 --- /dev/null +++ b/ui/app/routes/vault/cluster/settings.js @@ -0,0 +1,8 @@ +import Ember from 'ember'; +import ClusterRoute from 'vault/mixins/cluster-route'; + +export default Ember.Route.extend(ClusterRoute, { + model() { + return {}; + }, +}); diff --git a/ui/app/routes/vault/cluster/settings/auth/configure.js b/ui/app/routes/vault/cluster/settings/auth/configure.js new file mode 100644 index 000000000..c644c98e3 --- /dev/null +++ b/ui/app/routes/vault/cluster/settings/auth/configure.js @@ -0,0 +1,22 @@ +import Ember from 'ember'; +import DS from 'ember-data'; + +import { methods } from 'vault/helpers/mountable-auth-methods'; + +const METHODS = methods(); + +export default Ember.Route.extend({ + model() { + const { method } = this.paramsFor(this.routeName); + return this.store.findAll('auth-method').then(() => { + const model = this.store.peekRecord('auth-method', method); + const modelType = model && model.get('type'); + if (!model || (modelType !== 'token' && !METHODS.findBy('type', modelType))) { + const error = new DS.AdapterError(); + Ember.set(error, 'httpStatus', 404); + throw error; + } + return model; + }); + }, +}); diff --git a/ui/app/routes/vault/cluster/settings/auth/configure/index.js b/ui/app/routes/vault/cluster/settings/auth/configure/index.js new file mode 100644 index 000000000..438ffeb14 --- /dev/null +++ b/ui/app/routes/vault/cluster/settings/auth/configure/index.js @@ -0,0 +1,12 @@ +import Ember from 'ember'; +import { tabsForAuthSection } from 'vault/helpers/tabs-for-auth-section'; + +const { get } = Ember; + +export default Ember.Route.extend({ + beforeModel() { + const type = this.modelFor('vault.cluster.settings.auth.configure').get('type'); + const section = get(tabsForAuthSection([type]), 'firstObject.routeParams.lastObject'); + return this.transitionTo('vault.cluster.settings.auth.configure.section', section); + }, +}); diff --git a/ui/app/routes/vault/cluster/settings/auth/configure/section.js b/ui/app/routes/vault/cluster/settings/auth/configure/section.js new file mode 100644 index 000000000..cfcdfd2ef --- /dev/null +++ b/ui/app/routes/vault/cluster/settings/auth/configure/section.js @@ -0,0 +1,80 @@ +import Ember from 'ember'; +import DS from 'ember-data'; +import UnloadModelRoute from 'vault/mixins/unload-model-route'; + +const { RSVP } = Ember; +export default Ember.Route.extend(UnloadModelRoute, { + modelPath: 'model.model', + modelType(backendType, section) { + const MODELS = { + 'aws-client': 'auth-config/aws/client', + 'aws-identity-whitelist': 'auth-config/aws/identity-whitelist', + 'aws-roletag-blacklist': 'auth-config/aws/roletag-blacklist', + 'github-configuration': 'auth-config/github', + 'gcp-configuration': 'auth-config/gcp', + 'kubernetes-configuration': 'auth-config/kubernetes', + 'ldap-configuration': 'auth-config/ldap', + 'okta-configuration': 'auth-config/okta', + 'radius-configuration': 'auth-config/radius', + }; + return MODELS[`${backendType}-${section}`]; + }, + + model(params) { + const backend = this.modelFor('vault.cluster.settings.auth.configure'); + const { section_name: section } = params; + if (section === 'options') { + return RSVP.hash({ + model: backend, + section, + }); + } + const modelType = this.modelType(backend.get('type'), section); + if (!modelType) { + const error = new DS.AdapterError(); + Ember.set(error, 'httpStatus', 404); + throw error; + } + const model = this.store.peekRecord(modelType, backend.id); + if (model) { + return RSVP.hash({ + model, + section, + }); + } + return this.store + .findRecord(modelType, backend.id) + .then(config => { + config.set('backend', backend); + return RSVP.hash({ + model: config, + section, + }); + }) + .catch(e => { + let config; + // if you haven't saved a config, the API 404s, so create one here to edit and return it + if (e.httpStatus === 404) { + config = this.store.createRecord(modelType, { + id: backend.id, + }); + config.set('backend', backend); + + return RSVP.hash({ + model: config, + section, + }); + } + throw e; + }); + }, + + actions: { + willTransition() { + if (this.currentModel.model.constructor.modelName !== 'auth-method') { + this.unloadModel(); + return true; + } + }, + }, +}); diff --git a/ui/app/routes/vault/cluster/settings/auth/index.js b/ui/app/routes/vault/cluster/settings/auth/index.js new file mode 100644 index 000000000..0e8ce4270 --- /dev/null +++ b/ui/app/routes/vault/cluster/settings/auth/index.js @@ -0,0 +1,7 @@ +import Ember from 'ember'; + +export default Ember.Route.extend({ + beforeModel() { + return this.replaceWith('vault.cluster.settings.auth.enable'); + }, +}); diff --git a/ui/app/routes/vault/cluster/settings/configure-secret-backend.js b/ui/app/routes/vault/cluster/settings/configure-secret-backend.js new file mode 100644 index 000000000..3c6217dff --- /dev/null +++ b/ui/app/routes/vault/cluster/settings/configure-secret-backend.js @@ -0,0 +1,65 @@ +import Ember from 'ember'; +import DS from 'ember-data'; + +const CONFIGURABLE_BACKEND_TYPES = ['aws', 'ssh', 'pki']; + +export default Ember.Route.extend({ + model() { + const { backend } = this.paramsFor(this.routeName); + return this.store.query('secret-engine', {}).then(() => { + const model = this.store.peekRecord('secret-engine', backend); + if (!model || !CONFIGURABLE_BACKEND_TYPES.includes(model.get('type'))) { + const error = new DS.AdapterError(); + Ember.set(error, 'httpStatus', 404); + throw error; + } + return this.store.findRecord('secret-engine', backend).then( + () => { + return model; + }, + () => { + return model; + } + ); + }); + }, + + afterModel(model, transition) { + const type = model.get('type'); + if (type === 'pki') { + if (transition.targetName === this.routeName) { + return this.transitionTo(`${this.routeName}.section`, 'cert'); + } else { + return; + } + } + if (type === 'aws') { + return this.store + .queryRecord('secret-engine', { + backend: model.id, + type, + }) + .then(() => model, () => model); + } + return model; + }, + + setupController(controller, model) { + if (model.get('publicKey')) { + controller.set('configured', true); + } + return this._super(...arguments); + }, + + resetController(controller, isExiting) { + if (isExiting) { + controller.reset(); + } + }, + + actions: { + refreshRoute() { + this.refresh(); + }, + }, +}); diff --git a/ui/app/routes/vault/cluster/settings/configure-secret-backend/index.js b/ui/app/routes/vault/cluster/settings/configure-secret-backend/index.js new file mode 100644 index 000000000..245a2d4fe --- /dev/null +++ b/ui/app/routes/vault/cluster/settings/configure-secret-backend/index.js @@ -0,0 +1,10 @@ +import Ember from 'ember'; + +export default Ember.Route.extend({ + beforeModel(transition) { + const type = this.modelFor('vault.cluster.settings.configure-secret-backend').get('type'); + if (type === 'pki' && transition.targetName === this.routeName) { + return this.transitionTo('vault.cluster.settings.configure-secret-backend.section', 'cert'); + } + }, +}); diff --git a/ui/app/routes/vault/cluster/settings/configure-secret-backend/section.js b/ui/app/routes/vault/cluster/settings/configure-secret-backend/section.js new file mode 100644 index 000000000..4e0b30e79 --- /dev/null +++ b/ui/app/routes/vault/cluster/settings/configure-secret-backend/section.js @@ -0,0 +1,41 @@ +import Ember from 'ember'; +import DS from 'ember-data'; + +const SECTIONS_FOR_TYPE = { + pki: ['cert', 'urls', 'crl', 'tidy'], +}; +export default Ember.Route.extend({ + fetchModel() { + const { section_name: sectionName } = this.paramsFor(this.routeName); + const backendModel = this.modelFor('vault.cluster.settings.configure-secret-backend'); + const modelType = `${backendModel.get('type')}-config`; + return this.store + .queryRecord(modelType, { + backend: backendModel.id, + section: sectionName, + }) + .then(model => { + model.set('backendType', backendModel.get('type')); + model.set('section', sectionName); + return model; + }); + }, + + model(params) { + const { section_name: sectionName } = params; + const backendModel = this.modelFor('vault.cluster.settings.configure-secret-backend'); + const sections = SECTIONS_FOR_TYPE[backendModel.get('type')]; + const hasSection = sections.includes(sectionName); + if (!backendModel || !hasSection) { + const error = new DS.AdapterError(); + Ember.set(error, 'httpStatus', 404); + throw error; + } + return this.fetchModel(); + }, + + setupController(controller) { + this._super(...arguments); + controller.set('onRefresh', () => this.fetchModel()); + }, +}); diff --git a/ui/app/routes/vault/cluster/settings/index.js b/ui/app/routes/vault/cluster/settings/index.js new file mode 100644 index 000000000..779430929 --- /dev/null +++ b/ui/app/routes/vault/cluster/settings/index.js @@ -0,0 +1,10 @@ +import Ember from 'ember'; + +export default Ember.Route.extend({ + beforeModel: function(transition) { + if (transition.targetName === this.routeName) { + transition.abort(); + this.replaceWith('vault.cluster.settings.mount-secret-backend'); + } + }, +}); diff --git a/ui/app/routes/vault/cluster/settings/mount-secret-backend.js b/ui/app/routes/vault/cluster/settings/mount-secret-backend.js new file mode 100644 index 000000000..42208967e --- /dev/null +++ b/ui/app/routes/vault/cluster/settings/mount-secret-backend.js @@ -0,0 +1,14 @@ +import Ember from 'ember'; +import UnloadModelRoute from 'vault/mixins/unload-model-route'; +import UnsavedModelRoute from 'vault/mixins/unsaved-model-route'; + +export default Ember.Route.extend(UnloadModelRoute, UnsavedModelRoute, { + // intentionally blank - we don't want a model until one is + // created via the form in the controller + model() { + return {}; + }, + activate() { + this.store.unloadAll('secret-engine'); + }, +}); diff --git a/ui/app/routes/vault/cluster/settings/seal.js b/ui/app/routes/vault/cluster/settings/seal.js new file mode 100644 index 000000000..d64a10305 --- /dev/null +++ b/ui/app/routes/vault/cluster/settings/seal.js @@ -0,0 +1,10 @@ +import Ember from 'ember'; + +export default Ember.Route.extend({ + model() { + return Ember.RSVP.hash({ + cluster: this.modelFor('vault.cluster'), + seal: this.store.findRecord('capabilities', 'sys/seal'), + }); + }, +}); diff --git a/ui/app/routes/vault/cluster/tools.js b/ui/app/routes/vault/cluster/tools.js new file mode 100644 index 000000000..18552ac11 --- /dev/null +++ b/ui/app/routes/vault/cluster/tools.js @@ -0,0 +1,8 @@ +import Ember from 'ember'; +import ClusterRoute from 'vault/mixins/cluster-route'; + +export default Ember.Route.extend(ClusterRoute, { + model() { + return this.modelFor('vault.cluster'); + }, +}); diff --git a/ui/app/routes/vault/cluster/tools/index.js b/ui/app/routes/vault/cluster/tools/index.js new file mode 100644 index 000000000..8ecc01051 --- /dev/null +++ b/ui/app/routes/vault/cluster/tools/index.js @@ -0,0 +1,14 @@ +import Ember from 'ember'; +import { toolsActions } from 'vault/helpers/tools-actions'; + +export default Ember.Route.extend({ + currentCluster: Ember.inject.service(), + beforeModel(transition) { + const currentCluster = this.get('currentCluster.cluster.name'); + const supportedActions = toolsActions(); + if (transition.targetName === this.routeName) { + transition.abort(); + return this.replaceWith('vault.cluster.tools.tool', currentCluster, supportedActions[0]); + } + }, +}); diff --git a/ui/app/routes/vault/cluster/tools/tool.js b/ui/app/routes/vault/cluster/tools/tool.js new file mode 100644 index 000000000..b031d0d0c --- /dev/null +++ b/ui/app/routes/vault/cluster/tools/tool.js @@ -0,0 +1,20 @@ +import Ember from 'ember'; +import { toolsActions } from 'vault/helpers/tools-actions'; + +export default Ember.Route.extend({ + beforeModel(transition) { + const supportedActions = toolsActions(); + const { selectedAction } = this.paramsFor(this.routeName); + if (!selectedAction || !supportedActions.includes(selectedAction)) { + transition.abort(); + return this.transitionTo(this.routeName, supportedActions[0]); + } + }, + model() {}, + actions: { + didTransition() { + const params = this.paramsFor(this.routeName); + this.controller.setProperties(params); + }, + }, +}); diff --git a/ui/app/routes/vault/cluster/unseal.js b/ui/app/routes/vault/cluster/unseal.js new file mode 100644 index 000000000..875583f71 --- /dev/null +++ b/ui/app/routes/vault/cluster/unseal.js @@ -0,0 +1 @@ +export { default } from './cluster-route-base'; diff --git a/ui/app/serializers/application.js b/ui/app/serializers/application.js new file mode 100644 index 000000000..64826849f --- /dev/null +++ b/ui/app/serializers/application.js @@ -0,0 +1,62 @@ +import DS from 'ember-data'; +import Ember from 'ember'; +const { decamelize } = Ember.String; + +export default DS.JSONSerializer.extend({ + keyForAttribute: function(attr) { + return decamelize(attr); + }, + + normalizeItems(payload) { + if (payload.data && payload.data.keys && Array.isArray(payload.data.keys)) { + let models = payload.data.keys.map(key => { + let pk = this.get('primaryKey') || 'id'; + return { [pk]: key }; + }); + return models; + } + Ember.assign(payload, payload.data); + delete payload.data; + return payload; + }, + + pushPayload(store, payload) { + const transformedPayload = this.normalizeResponse( + store, + store.modelFor(payload.modelName), + payload, + payload.id, + 'findRecord' + ); + return store.push(transformedPayload); + }, + + normalizeResponse(store, primaryModelClass, payload, id, requestType) { + const responseJSON = this.normalizeItems(payload); + if (id && !responseJSON.id) { + responseJSON.id = id; + } + return this._super(store, primaryModelClass, responseJSON, id, requestType); + }, + + serializeAttribute(snapshot, json, key, attributes) { + const val = snapshot.attr(key); + const valHasNotChanged = Ember.isNone(snapshot.changedAttributes()[key]); + const valIsBlank = Ember.isBlank(val); + if (attributes.options.readOnly) { + return; + } + if (attributes.type === 'object' && val && Object.keys(val).length > 0 && valHasNotChanged) { + return; + } + if (valIsBlank && valHasNotChanged) { + return; + } + + this._super(snapshot, json, key, attributes); + }, + + serializeBelongsTo(snapshot, json) { + return json; + }, +}); diff --git a/ui/app/serializers/auth-method.js b/ui/app/serializers/auth-method.js new file mode 100644 index 000000000..77b2a7fb6 --- /dev/null +++ b/ui/app/serializers/auth-method.js @@ -0,0 +1,24 @@ +import ApplicationSerializer from './application'; + +export default ApplicationSerializer.extend({ + normalizeBackend(path, backend) { + let struct = {}; + for (let attribute in backend) { + struct[attribute] = backend[attribute]; + } + // strip the trailing slash off of the path so we + // can navigate to it without getting `//` in the url + struct.id = path.slice(0, -1); + struct.path = path; + return struct; + }, + + normalizeResponse(store, primaryModelClass, payload, id, requestType) { + const isCreate = requestType === 'createRecord'; + const backends = isCreate + ? payload.data + : Object.keys(payload.data).map(id => this.normalizeBackend(id, payload[id])); + + return this._super(store, primaryModelClass, backends, id, requestType); + }, +}); diff --git a/ui/app/serializers/capabilities.js b/ui/app/serializers/capabilities.js new file mode 100644 index 000000000..c9212ea93 --- /dev/null +++ b/ui/app/serializers/capabilities.js @@ -0,0 +1,20 @@ +import DS from 'ember-data'; + +export default DS.RESTSerializer.extend({ + primaryKey: 'path', + + normalizeResponse(store, primaryModelClass, payload, id, requestType) { + // queryRecord will already have set this, and we won't have an id here + if (id) { + payload.path = id; + } + const response = { + [primaryModelClass.modelName]: payload, + }; + return this._super(store, primaryModelClass, response, id, requestType); + }, + + modelNameFromPayloadKey() { + return 'capabilities'; + }, +}); diff --git a/ui/app/serializers/cluster.js b/ui/app/serializers/cluster.js new file mode 100644 index 000000000..5c552381a --- /dev/null +++ b/ui/app/serializers/cluster.js @@ -0,0 +1,34 @@ +import Ember from 'ember'; +import DS from 'ember-data'; + +const { decamelize } = Ember.String; + +export default DS.RESTSerializer.extend(DS.EmbeddedRecordsMixin, { + keyForAttribute: function(attr) { + return decamelize(attr); + }, + + attrs: { + nodes: { embedded: 'always' }, + }, + + pushPayload(store, payload) { + const transformedPayload = this.normalizeResponse( + store, + store.modelFor('cluster'), + payload, + null, + 'findAll' + ); + return store.push(transformedPayload); + }, + + normalizeResponse(store, primaryModelClass, payload, id, requestType) { + // FIXME when multiple clusters lands + const transformedPayload = { + clusters: Ember.assign({ id: '1' }, payload.data || payload), + }; + + return this._super(store, primaryModelClass, transformedPayload, id, requestType); + }, +}); diff --git a/ui/app/serializers/config.js b/ui/app/serializers/config.js new file mode 100644 index 000000000..80314d93c --- /dev/null +++ b/ui/app/serializers/config.js @@ -0,0 +1,29 @@ +import DS from 'ember-data'; +import Ember from 'ember'; +const { decamelize } = Ember.String; + +export default DS.RESTSerializer.extend({ + keyForAttribute: function(attr) { + return decamelize(attr); + }, + + normalizeAll(payload) { + if (payload.data) { + const data = Ember.assign({}, payload, payload.data); + return [data]; + } + return [payload]; + }, + + normalizeResponse(store, primaryModelClass, payload, id, requestType) { + const records = this.normalizeAll(payload); + const { modelName } = primaryModelClass; + let transformedPayload = { [modelName]: records }; + // just return the single object because ember is picky + if (requestType === 'queryRecord') { + transformedPayload = { [modelName]: records[0] }; + } + + return this._super(store, primaryModelClass, transformedPayload, id, requestType); + }, +}); diff --git a/ui/app/serializers/iam-credential.js b/ui/app/serializers/iam-credential.js new file mode 100644 index 000000000..3b0943f0e --- /dev/null +++ b/ui/app/serializers/iam-credential.js @@ -0,0 +1,34 @@ +import DS from 'ember-data'; +import Ember from 'ember'; +const { decamelize } = Ember.String; + +export default DS.RESTSerializer.extend({ + keyForAttribute: function(attr) { + return decamelize(attr); + }, + + pushPayload(store, payload) { + const transformedPayload = this.normalizeResponse( + store, + store.modelFor(payload.modelName), + payload, + payload.id, + 'findRecord' + ); + return store.push(transformedPayload); + }, + + normalizeItems(payload) { + Ember.assign(payload, payload.data); + delete payload.data; + return payload; + }, + + normalizeResponse(store, primaryModelClass, payload, id, requestType) { + const responseJSON = this.normalizeItems(payload); + const { modelName } = primaryModelClass; + let transformedPayload = { [modelName]: responseJSON }; + let ret = this._super(store, primaryModelClass, transformedPayload, id, requestType); + return ret; + }, +}); diff --git a/ui/app/serializers/identity/entity.js b/ui/app/serializers/identity/entity.js new file mode 100644 index 000000000..03aee2693 --- /dev/null +++ b/ui/app/serializers/identity/entity.js @@ -0,0 +1,8 @@ +import DS from 'ember-data'; +import ApplicationSerializer from '../application'; + +export default ApplicationSerializer.extend(DS.EmbeddedRecordsMixin, { + attrs: { + aliases: { embedded: 'always' }, + }, +}); diff --git a/ui/app/serializers/identity/group.js b/ui/app/serializers/identity/group.js new file mode 100644 index 000000000..c2df2fdf8 --- /dev/null +++ b/ui/app/serializers/identity/group.js @@ -0,0 +1,25 @@ +import DS from 'ember-data'; +import ApplicationSerializer from '../application'; + +export default ApplicationSerializer.extend(DS.EmbeddedRecordsMixin, { + attrs: { + alias: { embedded: 'always' }, + }, + + normalizeFindRecordResponse(store, primaryModelClass, payload) { + if (payload.alias && Object.keys(payload.alias).length === 0) { + delete payload.alias; + } + return this._super(...arguments); + }, + + serialize() { + let json = this._super(...arguments); + delete json.alias; + if (json.type === 'external') { + delete json.member_entity_ids; + delete json.member_group_ids; + } + return json; + }, +}); diff --git a/ui/app/serializers/lease.js b/ui/app/serializers/lease.js new file mode 100644 index 000000000..41765bc20 --- /dev/null +++ b/ui/app/serializers/lease.js @@ -0,0 +1,32 @@ +import DS from 'ember-data'; +import Ember from 'ember'; +const { decamelize } = Ember.String; + +export default DS.RESTSerializer.extend({ + keyForAttribute: function(attr) { + return decamelize(attr); + }, + + normalizeAll(payload) { + if (payload.data.keys && Array.isArray(payload.data.keys)) { + const records = payload.data.keys.map(record => { + const fullPath = payload.prefix ? payload.prefix + record : record; + return { id: fullPath }; + }); + return records; + } + return [payload.data]; + }, + + normalizeResponse(store, primaryModelClass, payload, id, requestType) { + const records = this.normalizeAll(payload); + const { modelName } = primaryModelClass; + let transformedPayload = { [modelName]: records }; + // just return the single object because ember is picky + if (requestType === 'queryRecord') { + transformedPayload = { [modelName]: records[0] }; + } + + return this._super(store, primaryModelClass, transformedPayload, id, requestType); + }, +}); diff --git a/ui/app/serializers/mount-config.js b/ui/app/serializers/mount-config.js new file mode 100644 index 000000000..16289da25 --- /dev/null +++ b/ui/app/serializers/mount-config.js @@ -0,0 +1,2 @@ +import ApplicationSerializer from './application'; +export default ApplicationSerializer.extend(); diff --git a/ui/app/serializers/mount-filter-config.js b/ui/app/serializers/mount-filter-config.js new file mode 100644 index 000000000..1effd5741 --- /dev/null +++ b/ui/app/serializers/mount-filter-config.js @@ -0,0 +1,16 @@ +import DS from 'ember-data'; +import Ember from 'ember'; +const { decamelize } = Ember.String; + +export default DS.RESTSerializer.extend({ + keyForAttribute: function(attr) { + return decamelize(attr); + }, + + normalizeResponse(store, primaryModelClass, payload, id, requestType) { + const { modelName } = primaryModelClass; + payload.data.id = id; + const transformedPayload = { [modelName]: payload.data }; + return this._super(store, primaryModelClass, transformedPayload, id, requestType); + }, +}); diff --git a/ui/app/serializers/node.js b/ui/app/serializers/node.js new file mode 100644 index 000000000..582031350 --- /dev/null +++ b/ui/app/serializers/node.js @@ -0,0 +1,47 @@ +import Ember from 'ember'; +import DS from 'ember-data'; + +const { decamelize } = Ember.String; + +export default DS.RESTSerializer.extend(DS.EmbeddedRecordsMixin, { + keyForAttribute: function(attr) { + return decamelize(attr); + }, + + pushPayload(store, payload) { + const transformedPayload = this.normalizeResponse( + store, + store.modelFor('node'), + payload, + null, + 'findAll' + ); + return store.push(transformedPayload); + }, + + nodeFromObject(name, payload) { + const nodeObj = payload.nodes[name]; + return Ember.assign(nodeObj, { + name, + id: name, + }); + }, + + normalizeResponse(store, primaryModelClass, payload, id, requestType) { + // payload looks like: + // { "nodes": { "name": { "sealed": "true" }}} + + const nodes = payload.nodes + ? Object.keys(payload.nodes).map(name => this.nodeFromObject(name, payload)) + : [Ember.assign(payload, { id: '1' })]; + + const transformedPayload = { nodes: nodes }; + + return this._super(store, primaryModelClass, transformedPayload, id, requestType); + }, + + normalize(model, hash, prop) { + hash.id = '1'; + return this._super(model, hash, prop); + }, +}); diff --git a/ui/app/serializers/pki-certificate.js b/ui/app/serializers/pki-certificate.js new file mode 100644 index 000000000..4588eb238 --- /dev/null +++ b/ui/app/serializers/pki-certificate.js @@ -0,0 +1,63 @@ +import DS from 'ember-data'; +import Ember from 'ember'; +const { decamelize } = Ember.String; + +export default DS.RESTSerializer.extend({ + keyForAttribute: function(attr) { + return decamelize(attr); + }, + + pushPayload(store, payload) { + const transformedPayload = this.normalizeResponse( + store, + store.modelFor(payload.modelName), + payload, + payload.id, + 'findRecord' + ); + return store.push(transformedPayload); + }, + + normalizeItems(payload) { + if (payload.data && payload.data.keys && Array.isArray(payload.data.keys)) { + let ret = payload.data.keys.map(key => { + let model = { + id_for_nav: `cert/${key}`, + id: key, + }; + if (payload.backend) { + model.backend = payload.backend; + } + return model; + }); + return ret; + } + Ember.assign(payload, payload.data); + delete payload.data; + return payload; + }, + + normalizeResponse(store, primaryModelClass, payload, id, requestType) { + const responseJSON = this.normalizeItems(payload); + const { modelName } = primaryModelClass; + let transformedPayload = { [modelName]: responseJSON }; + return this._super(store, primaryModelClass, transformedPayload, id, requestType); + }, + + serializeAttribute(snapshot, json, key, attributes) { + const val = snapshot.attr(key); + const valHasNotChanged = Ember.isNone(snapshot.changedAttributes()[key]); + const valIsBlank = Ember.isBlank(val); + if (attributes.options.readOnly) { + return; + } + if (attributes.type === 'object' && val && Object.keys(val).length > 0 && valHasNotChanged) { + return; + } + if (valIsBlank && valHasNotChanged) { + return; + } + + this._super(snapshot, json, key, attributes); + }, +}); diff --git a/ui/app/serializers/policy.js b/ui/app/serializers/policy.js new file mode 100644 index 000000000..9646bbb94 --- /dev/null +++ b/ui/app/serializers/policy.js @@ -0,0 +1,18 @@ +import ApplicationSerializer from './application'; + +export default ApplicationSerializer.extend({ + primaryKey: 'name', + + normalizePolicies(payload) { + const data = payload.data.keys ? payload.data.keys.map(name => ({ name })) : payload.data; + return data; + }, + + normalizeResponse(store, primaryModelClass, payload, id, requestType) { + const nullResponses = ['deleteRecord']; + let normalizedPayload = nullResponses.includes(requestType) + ? { name: id } + : this.normalizePolicies(payload); + return this._super(store, primaryModelClass, normalizedPayload, id, requestType); + }, +}); diff --git a/ui/app/serializers/policy/acl.js b/ui/app/serializers/policy/acl.js new file mode 100644 index 000000000..ea1e2c990 --- /dev/null +++ b/ui/app/serializers/policy/acl.js @@ -0,0 +1,3 @@ +import PolicySerializer from '../policy'; + +export default PolicySerializer.extend(); diff --git a/ui/app/serializers/policy/egp.js b/ui/app/serializers/policy/egp.js new file mode 100644 index 000000000..ea1e2c990 --- /dev/null +++ b/ui/app/serializers/policy/egp.js @@ -0,0 +1,3 @@ +import PolicySerializer from '../policy'; + +export default PolicySerializer.extend(); diff --git a/ui/app/serializers/policy/rgp.js b/ui/app/serializers/policy/rgp.js new file mode 100644 index 000000000..ea1e2c990 --- /dev/null +++ b/ui/app/serializers/policy/rgp.js @@ -0,0 +1,3 @@ +import PolicySerializer from '../policy'; + +export default PolicySerializer.extend(); diff --git a/ui/app/serializers/replication-attributes.js b/ui/app/serializers/replication-attributes.js new file mode 100644 index 000000000..558e1a95e --- /dev/null +++ b/ui/app/serializers/replication-attributes.js @@ -0,0 +1,10 @@ +import Ember from 'ember'; +import DS from 'ember-data'; + +const { decamelize } = Ember.String; + +export default DS.RESTSerializer.extend({ + keyForAttribute: function(attr) { + return decamelize(attr); + }, +}); diff --git a/ui/app/serializers/role-aws.js b/ui/app/serializers/role-aws.js new file mode 100644 index 000000000..41878b351 --- /dev/null +++ b/ui/app/serializers/role-aws.js @@ -0,0 +1,71 @@ +import DS from 'ember-data'; +import Ember from 'ember'; +const { decamelize } = Ember.String; + +export default DS.RESTSerializer.extend({ + keyForAttribute: function(attr) { + return decamelize(attr); + }, + + extractLazyPaginatedData(payload) { + let ret; + ret = payload.data.keys.map(key => { + let model = { + id: key, + }; + if (payload.backend) { + model.backend = payload.backend; + } + return model; + }); + return ret; + }, + + normalizeItems(payload) { + if (payload.data.keys && Array.isArray(payload.data.keys)) { + return payload.data.keys; + } + Ember.assign(payload, payload.data); + delete payload.data; + return [payload]; + }, + modelNameFromPayloadKey(payloadType) { + return payloadType; + }, + + normalizeResponse(store, primaryModelClass, payload, id, requestType) { + const nullResponses = ['updateRecord', 'createRecord', 'deleteRecord']; + const responseJSON = nullResponses.includes(requestType) ? { id } : this.normalizeItems(payload); + const { modelName } = primaryModelClass; + let transformedPayload = { [modelName]: responseJSON }; + // just return the single object because ember is picky + if (requestType === 'queryRecord') { + transformedPayload = { [modelName]: responseJSON[0] }; + } + + return this._super(store, primaryModelClass, transformedPayload, id, requestType); + }, + + serializeAttribute(snapshot, json, key, attributes) { + const val = snapshot.attr(key); + if (attributes.options.readOnly) { + return; + } + if ( + attributes.type === 'object' && + val && + Object.keys(val).length > 0 && + Ember.isNone(snapshot.changedAttributes()[key]) + ) { + return; + } + if (Ember.isBlank(val) && Ember.isNone(snapshot.changedAttributes()[key])) { + return; + } + + this._super(snapshot, json, key, attributes); + }, + serialize() { + return this._super(...arguments); + }, +}); diff --git a/ui/app/serializers/role-pki.js b/ui/app/serializers/role-pki.js new file mode 100644 index 000000000..af9f7c23d --- /dev/null +++ b/ui/app/serializers/role-pki.js @@ -0,0 +1,66 @@ +import DS from 'ember-data'; +import Ember from 'ember'; +const { decamelize } = Ember.String; + +export default DS.RESTSerializer.extend({ + keyForAttribute: function(attr) { + return decamelize(attr); + }, + + normalizeItems(payload) { + if (payload.data.keys && Array.isArray(payload.data.keys)) { + let ret = payload.data.keys.map(key => { + let model = { + id: key, + }; + if (payload.backend) { + model.backend = payload.backend; + } + return model; + }); + return ret; + } + Ember.assign(payload, payload.data); + delete payload.data; + return [payload]; + }, + modelNameFromPayloadKey(payloadType) { + return payloadType; + }, + + normalizeResponse(store, primaryModelClass, payload, id, requestType) { + const nullResponses = ['updateRecord', 'createRecord', 'deleteRecord']; + const responseJSON = nullResponses.includes(requestType) ? { id } : this.normalizeItems(payload); + const { modelName } = primaryModelClass; + let transformedPayload = { [modelName]: responseJSON }; + // just return the single object because ember is picky + if (requestType === 'queryRecord') { + transformedPayload = { [modelName]: responseJSON[0] }; + } + + return this._super(store, primaryModelClass, transformedPayload, id, requestType); + }, + + serializeAttribute(snapshot, json, key, attributes) { + const val = snapshot.attr(key); + if (attributes.options.readOnly) { + return; + } + if ( + attributes.type === 'object' && + val && + Object.keys(val).length > 0 && + Ember.isNone(snapshot.changedAttributes()[key]) + ) { + return; + } + if (Ember.isBlank(val) && Ember.isNone(snapshot.changedAttributes()[key])) { + return; + } + + this._super(snapshot, json, key, attributes); + }, + serialize() { + return this._super(...arguments); + }, +}); diff --git a/ui/app/serializers/role-ssh.js b/ui/app/serializers/role-ssh.js new file mode 100644 index 000000000..d36af6b2f --- /dev/null +++ b/ui/app/serializers/role-ssh.js @@ -0,0 +1,3 @@ +import RoleSerializer from './role'; + +export default RoleSerializer.extend(); diff --git a/ui/app/serializers/role.js b/ui/app/serializers/role.js new file mode 100644 index 000000000..b8f86a1d2 --- /dev/null +++ b/ui/app/serializers/role.js @@ -0,0 +1,80 @@ +import DS from 'ember-data'; +import Ember from 'ember'; +const { decamelize } = Ember.String; + +export default DS.RESTSerializer.extend({ + keyForAttribute: function(attr) { + return decamelize(attr); + }, + + extractLazyPaginatedData(payload) { + let ret; + if (payload.zero_address_roles) { + payload.zero_address_roles.forEach(role => { + // mutate key_info object to add zero_address info + payload.data.key_info[role].zero_address = true; + }); + } + ret = payload.data.keys.map(key => { + let model = { + id: key, + key_type: payload.data.key_info[key].key_type, + zero_address: payload.data.key_info[key].zero_address, + }; + if (payload.backend) { + model.backend = payload.backend; + } + return model; + }); + delete payload.data.key_info; + return ret; + }, + + normalizeItems(payload) { + if (payload.data.keys && Array.isArray(payload.data.keys)) { + return payload.data.keys; + } + Ember.assign(payload, payload.data); + delete payload.data; + return [payload]; + }, + modelNameFromPayloadKey(payloadType) { + return payloadType; + }, + + normalizeResponse(store, primaryModelClass, payload, id, requestType) { + const nullResponses = ['updateRecord', 'createRecord', 'deleteRecord']; + const responseJSON = nullResponses.includes(requestType) ? { id } : this.normalizeItems(payload); + const { modelName } = primaryModelClass; + let transformedPayload = { [modelName]: responseJSON }; + // just return the single object because ember is picky + if (requestType === 'queryRecord') { + transformedPayload = { [modelName]: responseJSON[0] }; + } + + return this._super(store, primaryModelClass, transformedPayload, id, requestType); + }, + + serializeAttribute(snapshot, json, key, attributes) { + const val = snapshot.attr(key); + if (attributes.options.readOnly) { + return; + } + if ( + attributes.type === 'object' && + val && + Object.keys(val).length > 0 && + Ember.isNone(snapshot.changedAttributes()[key]) + ) { + return; + } + if (Ember.isBlank(val) && Ember.isNone(snapshot.changedAttributes()[key])) { + return; + } + + this._super(snapshot, json, key, attributes); + }, + serialize() { + return this._super(...arguments); + }, +}); diff --git a/ui/app/serializers/secret-cubbyhole.js b/ui/app/serializers/secret-cubbyhole.js new file mode 100644 index 000000000..079c9bf84 --- /dev/null +++ b/ui/app/serializers/secret-cubbyhole.js @@ -0,0 +1,42 @@ +import DS from 'ember-data'; +import Ember from 'ember'; +const { decamelize } = Ember.String; + +export default DS.RESTSerializer.extend({ + keyForAttribute: function(attr) { + return decamelize(attr); + }, + + normalizeSecrets(payload) { + if (payload.data.keys && Array.isArray(payload.data.keys)) { + const secrets = payload.data.keys.map(secret => { + let fullSecretPath = payload.id ? payload.id + secret : secret; + if (!fullSecretPath) { + fullSecretPath = '\u0020'; + } + return { id: fullSecretPath }; + }); + return secrets; + } + payload.secret_data = payload.data; + delete payload.data; + return [payload]; + }, + + normalizeResponse(store, primaryModelClass, payload, id, requestType) { + const nullResponses = ['updateRecord', 'createRecord', 'deleteRecord']; + const secrets = nullResponses.includes(requestType) ? { id } : this.normalizeSecrets(payload); + const { modelName } = primaryModelClass; + let transformedPayload = { [modelName]: secrets }; + // just return the single object because ember is picky + if (requestType === 'queryRecord') { + transformedPayload = { [modelName]: secrets[0] }; + } + + return this._super(store, primaryModelClass, transformedPayload, id, requestType); + }, + + serialize(snapshot) { + return snapshot.attr('secretData'); + }, +}); diff --git a/ui/app/serializers/secret-engine.js b/ui/app/serializers/secret-engine.js new file mode 100644 index 000000000..c14495549 --- /dev/null +++ b/ui/app/serializers/secret-engine.js @@ -0,0 +1,51 @@ +import DS from 'ember-data'; +import Ember from 'ember'; +const { decamelize } = Ember.String; + +export default DS.RESTSerializer.extend({ + keyForAttribute: function(attr) { + return decamelize(attr); + }, + normalizeBackend(path, backend) { + let struct = {}; + for (let attribute in backend) { + struct[attribute] = backend[attribute]; + } + //queryRecord adds path to the response + if (path !== null && !struct.path) { + struct.path = path; + } + + if (struct.data) { + struct = Ember.assign({}, struct, struct.data); + delete struct.data; + } + // strip the trailing slash off of the path so we + // can navigate to it without getting `//` in the url + struct.id = struct.path.slice(0, -1); + return struct; + }, + + normalizeResponse(store, primaryModelClass, payload, id, requestType) { + const isCreate = requestType === 'createRecord'; + const isFind = requestType === 'findRecord'; + const isQueryRecord = requestType === 'queryRecord'; + let backends; + if (isCreate) { + backends = payload.data; + } else if (isFind) { + backends = this.normalizeBackend(id + '/', payload.data); + } else if (isQueryRecord) { + backends = this.normalizeBackend(null, payload); + } else { + backends = Object.keys(payload.data).map(id => this.normalizeBackend(id, payload[id])); + } + + const transformedPayload = { [primaryModelClass.modelName]: backends }; + return this._super(store, primaryModelClass, transformedPayload, id, requestType); + }, + + serialize() { + return this._super(...arguments); + }, +}); diff --git a/ui/app/serializers/secret.js b/ui/app/serializers/secret.js new file mode 100644 index 000000000..91b1984e9 --- /dev/null +++ b/ui/app/serializers/secret.js @@ -0,0 +1,42 @@ +import DS from 'ember-data'; +import Ember from 'ember'; +const { decamelize } = Ember.String; + +export default DS.RESTSerializer.extend({ + keyForAttribute: function(attr) { + return decamelize(attr); + }, + + normalizeSecrets(payload) { + if (payload.data.keys && Array.isArray(payload.data.keys)) { + const secrets = payload.data.keys.map(secret => { + let fullSecretPath = payload.id ? payload.id + secret : secret; + if (!fullSecretPath) { + fullSecretPath = '\u0020'; + } + return { id: fullSecretPath }; + }); + return secrets; + } + payload.secret_data = payload.data.data; + delete payload.data; + return [payload]; + }, + + normalizeResponse(store, primaryModelClass, payload, id, requestType) { + const nullResponses = ['updateRecord', 'createRecord', 'deleteRecord']; + const secrets = nullResponses.includes(requestType) ? { id } : this.normalizeSecrets(payload); + const { modelName } = primaryModelClass; + let transformedPayload = { [modelName]: secrets }; + // just return the single object because ember is picky + if (requestType === 'queryRecord') { + transformedPayload = { [modelName]: secrets[0] }; + } + + return this._super(store, primaryModelClass, transformedPayload, id, requestType); + }, + + serialize(snapshot) { + return snapshot.attr('secretData'); + }, +}); diff --git a/ui/app/serializers/ssh.js b/ui/app/serializers/ssh.js new file mode 100644 index 000000000..a49f045af --- /dev/null +++ b/ui/app/serializers/ssh.js @@ -0,0 +1,54 @@ +import DS from 'ember-data'; +import Ember from 'ember'; +const { decamelize } = Ember.String; + +export default DS.RESTSerializer.extend({ + keyForAttribute: function(attr) { + return decamelize(attr); + }, + + pushPayload(store, payload) { + const transformedPayload = this.normalizeResponse( + store, + store.modelFor(payload.modelName), + payload, + payload.id, + 'findRecord' + ); + return store.push(transformedPayload); + }, + + normalizeItems(payload) { + Ember.assign(payload, payload.data); + delete payload.data; + return payload; + }, + + normalizeResponse(store, primaryModelClass, payload, id, requestType) { + const responseJSON = this.normalizeItems(payload); + const { modelName } = primaryModelClass; + let transformedPayload = { [modelName]: responseJSON }; + let ret = this._super(store, primaryModelClass, transformedPayload, id, requestType); + return ret; + }, + + serializeAttribute(snapshot, json, key, attributes) { + const val = snapshot.attr(key); + if (attributes.options.readOnly) { + return; + } + if ( + attributes.type === 'object' && + val && + Object.keys(val).length > 0 && + Ember.isNone(snapshot.changedAttributes()[key]) + ) { + return; + } + if (Ember.isBlank(val) && Ember.isNone(snapshot.changedAttributes()[key])) { + return; + } + + this._super(snapshot, json, key, attributes); + }, +}); diff --git a/ui/app/serializers/transit-key.js b/ui/app/serializers/transit-key.js new file mode 100644 index 000000000..5ace36a78 --- /dev/null +++ b/ui/app/serializers/transit-key.js @@ -0,0 +1,49 @@ +import DS from 'ember-data'; +import Ember from 'ember'; +const { decamelize } = Ember.String; + +export default DS.RESTSerializer.extend({ + primaryKey: 'name', + + keyForAttribute: function(attr) { + return decamelize(attr); + }, + + normalizeSecrets(payload) { + if (payload.data.keys && Array.isArray(payload.data.keys)) { + const secrets = payload.data.keys.map(secret => ({ name: secret })); + return secrets; + } + Ember.assign(payload, payload.data); + delete payload.data; + return [payload]; + }, + + normalizeResponse(store, primaryModelClass, payload, id, requestType) { + const nullResponses = ['updateRecord', 'createRecord', 'deleteRecord']; + const secrets = nullResponses.includes(requestType) ? { name: id } : this.normalizeSecrets(payload); + const { modelName } = primaryModelClass; + let transformedPayload = { [modelName]: secrets }; + // just return the single object because ember is picky + if (requestType === 'queryRecord') { + transformedPayload = { [modelName]: secrets[0] }; + } + + return this._super(store, primaryModelClass, transformedPayload, id, requestType); + }, + + serialize(snapshot, requestType) { + if (requestType === 'update') { + const min_decryption_version = snapshot.attr('minDecryptionVersion'); + const min_encryption_version = snapshot.attr('minEncryptionVersion'); + const deletion_allowed = snapshot.attr('deletionAllowed'); + return { + min_decryption_version, + min_encryption_version, + deletion_allowed, + }; + } else { + return this._super(...arguments); + } + }, +}); diff --git a/ui/app/services/auth.js b/ui/app/services/auth.js new file mode 100644 index 000000000..19cb8cc9c --- /dev/null +++ b/ui/app/services/auth.js @@ -0,0 +1,294 @@ +import Ember from 'ember'; +import getStorage from '../lib/token-storage'; +import ENV from 'vault/config/environment'; +import { supportedAuthBackends } from 'vault/helpers/supported-auth-backends'; + +const { get, isArray, computed, getOwner } = Ember; + +const TOKEN_SEPARATOR = '☃'; +const TOKEN_PREFIX = 'vault-'; +const ROOT_PREFIX = '🗝'; +const IDLE_TIMEOUT_MS = 3 * 60e3; +const BACKENDS = supportedAuthBackends(); + +export { TOKEN_SEPARATOR, TOKEN_PREFIX, ROOT_PREFIX }; + +export default Ember.Service.extend({ + expirationCalcTS: null, + init() { + this._super(...arguments); + this.checkForRootToken(); + }, + + clusterAdapter() { + return getOwner(this).lookup('adapter:cluster'); + }, + + tokens: computed(function() { + return this.getTokensFromStorage() || []; + }), + + generateTokenName({ backend, clusterId }, policies) { + return (policies || []).includes('root') + ? `${TOKEN_PREFIX}${ROOT_PREFIX}${TOKEN_SEPARATOR}${clusterId}` + : `${TOKEN_PREFIX}${backend}${TOKEN_SEPARATOR}${clusterId}`; + }, + + backendFromTokenName(tokenName) { + return tokenName.includes(`${TOKEN_PREFIX}${ROOT_PREFIX}`) + ? 'token' + : tokenName.slice(TOKEN_PREFIX.length).split(TOKEN_SEPARATOR)[0]; + }, + + storage(tokenName) { + if ( + tokenName && + tokenName.indexOf(`${TOKEN_PREFIX}${ROOT_PREFIX}`) === 0 && + this.environment() !== 'development' + ) { + return getStorage('memory'); + } else { + return getStorage(); + } + }, + + environment() { + return ENV.environment; + }, + + setCluster(clusterId) { + this.set('activeCluster', clusterId); + }, + + ajax(url, method, options) { + const defaults = { + url, + method, + dataType: 'json', + headers: { + 'X-Vault-Token': this.get('currentToken'), + }, + }; + return Ember.$.ajax(Ember.assign(defaults, options)); + }, + + renewCurrentToken() { + const url = '/v1/auth/token/renew-self'; + return this.ajax(url, 'POST'); + }, + + revokeCurrentToken() { + const url = '/v1/auth/token/revoke-self'; + return this.ajax(url, 'POST'); + }, + + calculateExpiration(resp, creationTime) { + const creationTTL = resp.creation_ttl || resp.lease_duration; + const leaseMilli = creationTTL ? creationTTL * 1e3 : null; + const tokenIssueEpoch = resp.creation_time ? resp.creation_time * 1e3 : creationTime || Date.now(); + const tokenExpirationEpoch = tokenIssueEpoch + leaseMilli; + const expirationData = { + tokenIssueEpoch, + tokenExpirationEpoch, + leaseMilli, + }; + this.set('expirationCalcTS', Date.now()); + return expirationData; + }, + + persistAuthData() { + const [firstArg, resp] = arguments; + let tokens = this.get('tokens'); + let tokenName; + let options; + let backend; + if (typeof firstArg === 'string') { + tokenName = firstArg; + backend = this.backendFromTokenName(tokenName); + } else { + options = firstArg; + backend = options.backend; + } + + const currentBackend = BACKENDS.findBy('type', backend); + let displayName; + if (isArray(currentBackend.displayNamePath)) { + displayName = currentBackend.displayNamePath.map(name => get(resp, name)).join('/'); + } else { + displayName = get(resp, currentBackend.displayNamePath); + } + + const { policies, renewable } = resp; + let data = { + displayName, + backend: currentBackend, + token: resp.client_token || get(resp, currentBackend.tokenPath), + policies, + renewable, + }; + + tokenName = this.generateTokenName( + { + backend, + clusterId: (options && options.clusterId) || this.get('activeCluster'), + }, + resp.policies + ); + + if (resp.renewable) { + Ember.assign(data, this.calculateExpiration(resp)); + } + + if (!data.displayName) { + data.displayName = get(this.getTokenData(tokenName) || {}, 'displayName'); + } + tokens.addObject(tokenName); + this.set('tokens', tokens); + this.set('allowExpiration', false); + this.setTokenData(tokenName, data); + return Ember.RSVP.resolve({ + token: tokenName, + isRoot: policies.includes('root'), + }); + }, + + setTokenData(token, data) { + this.storage(token).setItem(token, data); + }, + + getTokenData(token) { + return this.storage(token).getItem(token); + }, + + removeTokenData(token) { + return this.storage(token).removeItem(token); + }, + + tokenExpirationDate: computed('currentTokenName', 'expirationCalcTS', function() { + const tokenName = this.get('currentTokenName'); + if (!tokenName) { + return; + } + const { tokenExpirationEpoch } = this.getTokenData(tokenName); + const expirationDate = new Date(0); + return tokenExpirationEpoch ? expirationDate.setUTCMilliseconds(tokenExpirationEpoch) : null; + }), + + tokenExpired: computed(function() { + const expiration = this.get('tokenExpirationDate'); + return expiration ? Date.now() >= expiration : null; + }).volatile(), + + renewAfterEpoch: computed('currentTokenName', 'expirationCalcTS', function() { + const tokenName = this.get('currentTokenName'); + const data = this.getTokenData(tokenName); + if (!tokenName || !data) { + return null; + } + const { leaseMilli, tokenIssueEpoch, renewable } = data; + return data && renewable ? Math.floor(leaseMilli / 2) + tokenIssueEpoch : null; + }), + + renew() { + const tokenName = this.get('currentTokenName'); + const currentlyRenewing = this.get('isRenewing'); + if (currentlyRenewing) { + return; + } + this.set('isRenewing', true); + return this.renewCurrentToken().then( + resp => { + this.set('isRenewing', false); + return this.persistAuthData(tokenName, resp.data || resp.auth); + }, + e => { + this.set('isRenewing', false); + throw e; + } + ); + }, + + shouldRenew: computed(function() { + const now = Date.now(); + const lastFetch = this.get('lastFetch'); + const renewTime = this.get('renewAfterEpoch'); + if (this.get('tokenExpired') || this.get('allowExpiration') || !renewTime) { + return false; + } + if (lastFetch && now - lastFetch >= IDLE_TIMEOUT_MS) { + this.set('allowExpiration', true); + return false; + } + if (now >= renewTime) { + return true; + } + return false; + }).volatile(), + + setLastFetch(timestamp) { + this.set('lastFetch', timestamp); + }, + + getTokensFromStorage(filterFn) { + return this.storage().keys().reject(key => { + return key.indexOf(TOKEN_PREFIX) !== 0 || (filterFn && filterFn(key)); + }); + }, + + checkForRootToken() { + if (this.environment() === 'development') { + return; + } + this.getTokensFromStorage().forEach(key => { + const data = this.getTokenData(key); + if (data.policies.includes('root')) { + this.removeTokenData(key); + } + }); + }, + + authenticate(/*{clusterId, backend, data}*/) { + const [options] = arguments; + const adapter = this.clusterAdapter(); + + return adapter.authenticate(options).then(resp => { + return this.persistAuthData(options, resp.auth || resp.data); + }); + }, + + deleteCurrentToken() { + const tokenName = this.get('currentTokenName'); + this.deleteToken(tokenName); + this.removeTokenData(tokenName); + }, + + deleteToken(tokenName) { + const tokenNames = this.get('tokens').without(tokenName); + this.removeTokenData(tokenName); + this.set('tokens', tokenNames); + }, + + currentTokenName: computed('activeCluster', 'tokens.[]', function() { + const regex = new RegExp(this.get('activeCluster')); + return this.get('tokens').find(key => regex.test(key)); + }), + + currentToken: computed('currentTokenName', function() { + const name = this.get('currentTokenName'); + const data = name && this.getTokenData(name); + return name && data ? data.token : null; + }), + + authData: computed('currentTokenName', function() { + const token = this.get('currentTokenName'); + if (!token) { + return; + } + const backend = this.backendFromTokenName(token); + const stored = this.getTokenData(token); + + return Ember.assign(stored, { + backend: BACKENDS.findBy('type', backend), + }); + }), +}); diff --git a/ui/app/services/current-cluster.js b/ui/app/services/current-cluster.js new file mode 100644 index 000000000..e1bf4ba14 --- /dev/null +++ b/ui/app/services/current-cluster.js @@ -0,0 +1,9 @@ +import Ember from 'ember'; + +export default Ember.Service.extend({ + cluster: null, + + setCluster(cluster) { + this.set('cluster', cluster); + }, +}); diff --git a/ui/app/services/flash-messages.js b/ui/app/services/flash-messages.js new file mode 100644 index 000000000..ae0258579 --- /dev/null +++ b/ui/app/services/flash-messages.js @@ -0,0 +1,10 @@ +import FlashMessages from 'ember-cli-flash/services/flash-messages'; + +export default FlashMessages.extend({ + stickyInfo(message) { + return this.info(message, { + sticky: true, + priority: 300, + }); + }, +}); diff --git a/ui/app/services/replication-mode.js b/ui/app/services/replication-mode.js new file mode 100644 index 000000000..c5a1fc9f9 --- /dev/null +++ b/ui/app/services/replication-mode.js @@ -0,0 +1,13 @@ +import Ember from 'ember'; + +export default Ember.Service.extend({ + mode: null, + + getMode() { + this.get('mode'); + }, + + setMode(mode) { + this.set('mode', mode); + }, +}); diff --git a/ui/app/services/store.js b/ui/app/services/store.js new file mode 100644 index 000000000..bc092f923 --- /dev/null +++ b/ui/app/services/store.js @@ -0,0 +1,172 @@ +import DS from 'ember-data'; +import Ember from 'ember'; +import clamp from 'vault/utils/clamp'; + +const { assert, computed, get, set } = Ember; + +export function normalizeModelName(modelName) { + return Ember.String.dasherize(modelName); +} + +export function keyForCache(query) { + /*eslint no-unused-vars: ["error", { "ignoreRestSiblings": true }]*/ + // we want to ignore size, page, responsePath, and pageFilter in the cacheKey + const { size, page, responsePath, pageFilter, ...queryForCache } = query; + const cacheKeyObject = Object.keys(queryForCache).sort().reduce((result, key) => { + result[key] = queryForCache[key]; + return result; + }, {}); + return JSON.stringify(cacheKeyObject); +} + +export default DS.Store.extend({ + // this is a map of map that stores the caches + lazyCaches: computed(function() { + return new Map(); + }), + + setLazyCacheForModel(modelName, key, value) { + const cacheKey = keyForCache(key); + const cache = this.lazyCacheForModel(modelName) || new Map(); + cache.set(cacheKey, value); + const lazyCaches = this.get('lazyCaches'); + const modelKey = normalizeModelName(modelName); + lazyCaches.set(modelKey, cache); + }, + + getLazyCacheForModel(modelName, key) { + const cacheKey = keyForCache(key); + const modelCache = this.lazyCacheForModel(modelName); + if (modelCache) { + return modelCache.get(cacheKey); + } + }, + + lazyCacheForModel(modelName) { + return this.get('lazyCaches').get(normalizeModelName(modelName)); + }, + + // This is the public interface for the store extension - to be used just + // like `Store.query`. Special handling of the response is controlled by + // `query.pageFilter`, `query.page`, and `query.size`. + + // Required attributes of the `query` argument are: + // responsePath: a string indicating the location on the response where + // the array of items will be found + // page: the page number to return + // size: the size of the page + // pageFilter: a string that will be used to do a fuzzy match against the + // results, this is done pre-pagination + lazyPaginatedQuery(modelType, query /*, options*/) { + const adapter = this.adapterFor(modelType); + const modelName = normalizeModelName(modelType); + const dataCache = this.getDataset(modelName, query); + const responsePath = query.responsePath; + assert('responsePath is required', responsePath); + assert('page is required', typeof query.page === 'number'); + assert('size is required', query.size); + + if (dataCache) { + return Ember.RSVP.resolve(this.fetchPage(modelName, query)); + } + return adapter + .query(this, { modelName }, query) + .then(response => { + const serializer = this.serializerFor(modelName); + const datasetHelper = serializer.extractLazyPaginatedData; + const dataset = datasetHelper + ? datasetHelper.call(serializer, response) + : get(response, responsePath); + set(response, responsePath, null); + this.storeDataset(modelName, query, response, dataset); + return this.fetchPage(modelName, query); + }) + .catch(function(e) { + throw e; + }); + }, + + filterData(filter, dataset) { + let newData = dataset || []; + if (filter) { + newData = dataset.filter(function(item) { + const id = item.id || item; + return id.toLowerCase().includes(filter.toLowerCase()); + }); + } + return newData; + }, + + // reconstructs the original form of the response from the server + // with an additional `meta` block + // + // the meta block includes: + // currentPage, lastPage, nextPage, prevPage, total, filteredTotal + constructResponse(modelName, query) { + const { pageFilter, responsePath, size, page } = query; + let { response, dataset } = this.getDataset(modelName, query); + response = Ember.copy(response, true); + const data = this.filterData(pageFilter, dataset); + + const lastPage = Math.ceil(data.length / size); + const currentPage = clamp(page, 1, lastPage); + const end = currentPage * size; + const start = end - size; + const slicedDataSet = data.slice(start, end); + + set(response, responsePath || '', slicedDataSet); + + response.meta = { + currentPage, + lastPage, + nextPage: clamp(currentPage + 1, 1, lastPage), + prevPage: clamp(currentPage - 1, 1, lastPage), + total: get(dataset, 'length') || 0, + filteredTotal: get(data, 'length') || 0, + }; + + return response; + }, + + // pushes records into the store and returns the result + fetchPage(modelName, query) { + const response = this.constructResponse(modelName, query); + this.unloadAll(modelName); + this.push( + this.serializerFor(modelName).normalizeResponse(this, this.modelFor(modelName), response, null, 'query') + ); + const model = this.peekAll(modelName); + model.set('meta', response.meta); + return model; + }, + + // get cached data + getDataset(modelName, query) { + return this.getLazyCacheForModel(modelName, query); + }, + + // store data cache as { response, dataset} + // also populated `lazyCaches` attribute + storeDataset(modelName, query, response, array) { + const dataSet = { + response, + dataset: array, + }; + this.setLazyCacheForModel(modelName, query, dataSet); + }, + + clearDataset(modelName) { + let cacheList = this.get('lazyCaches'); + if (!cacheList.size) return; + if (modelName && cacheList.has(modelName)) { + cacheList.delete(modelName); + return; + } + cacheList.clear(); + this.set('lazyCaches', cacheList); + }, + + clearAllDatasets() { + this.clearDataset(); + }, +}); diff --git a/ui/app/services/version.js b/ui/app/services/version.js new file mode 100644 index 000000000..8236d82e3 --- /dev/null +++ b/ui/app/services/version.js @@ -0,0 +1,68 @@ +import Ember from 'ember'; +import { task } from 'ember-concurrency'; + +const { Service, inject, computed } = Ember; + +const hasFeature = featureKey => { + return computed('features', 'features.[]', function() { + const features = this.get('features'); + if (!features) { + return false; + } + return features.includes(featureKey); + }); +}; +export default Service.extend({ + _features: null, + features: computed.readOnly('_features'), + version: null, + store: inject.service(), + + hasPerfReplication: hasFeature('Performance Replication'), + + hasDRReplication: hasFeature('DR Replication'), + + isEnterprise: computed.match('version', /\+\w+$/), + + isOSS: computed.not('isEnterprise'), + + setVersion(resp) { + this.set('version', resp.version); + }, + + setFeatures(resp) { + if (!resp.features) { + return; + } + this.set('_features', resp.features); + }, + + getVersion: task(function*() { + if (this.get('version')) { + return; + } + let response = yield this.get('store').adapterFor('cluster').health(); + this.setVersion(response); + return; + }), + + getFeatures: task(function*() { + if (this.get('features.length') || this.get('isOSS')) { + return; + } + try { + let response = yield this.get('store').adapterFor('cluster').features(); + this.setFeatures(response); + return; + } catch (err) { + throw err; + } + }).keepLatest(), + + fetchVersion: function() { + return this.get('getVersion').perform(); + }, + fetchFeatures: function() { + return this.get('getFeatures').perform(); + }, +}); diff --git a/ui/app/styles/app.scss b/ui/app/styles/app.scss new file mode 100644 index 000000000..fe76b668a --- /dev/null +++ b/ui/app/styles/app.scss @@ -0,0 +1,2 @@ +@import "ember-basic-dropdown"; +@import "./core"; diff --git a/ui/app/styles/components/auth-form.scss b/ui/app/styles/components/auth-form.scss new file mode 100644 index 000000000..27e0229b8 --- /dev/null +++ b/ui/app/styles/components/auth-form.scss @@ -0,0 +1,5 @@ +.auth-form { + @extend .box; + @extend .is-bottomless; + padding: 0; +} diff --git a/ui/app/styles/components/b64-toggle.scss b/ui/app/styles/components/b64-toggle.scss new file mode 100644 index 000000000..676e9365e --- /dev/null +++ b/ui/app/styles/components/b64-toggle.scss @@ -0,0 +1,13 @@ +.b64-toggle { + padding: 0.75rem; + font-size: $size-9; +} +.b64-toggle.is-input { + box-shadow: none; +} +.b64-toggle.is-textarea { + @extend .is-compact; + position: absolute; + bottom: 0.25rem; + right: 0.25rem; +} diff --git a/ui/app/styles/components/badge.scss b/ui/app/styles/components/badge.scss new file mode 100644 index 000000000..de6001b55 --- /dev/null +++ b/ui/app/styles/components/badge.scss @@ -0,0 +1,24 @@ +.badge { + border: 1px solid $grey; + border-radius: 3px; + color: $grey-dark; + display: inline-block; + font-size: $size-9; + line-height: $size-7; + text-transform: uppercase; + margin-left: 0.5rem; + opacity: 0.6; + padding: 0.1rem 0.3rem; + + .navbar &, + .navbar-sections &, + .upgrade-overlay & { + border-color: $grey-light; + color: $white; + } + + .title & { + font-weight: $font-weight-normal; + vertical-align: middle; + } +} diff --git a/ui/app/styles/components/box-label.scss b/ui/app/styles/components/box-label.scss new file mode 100644 index 000000000..c536c26b6 --- /dev/null +++ b/ui/app/styles/components/box-label.scss @@ -0,0 +1,42 @@ +label.box-label { + cursor: pointer; +} +.box-label { + @extend .box; + @extend .is-centered; + @extend .is-gapless; + border-radius: 3px; + text-decoration: none; + width: 100%; + + > div:first-child { + flex-grow: 1; + } + + &.is-column { + @extend .is-flex-column; + } + &.is-selected { + box-shadow: 0 0 0 1px $blue; + } + + input[type=radio] { + display: none; + } + + input[type=radio] + label { + border: 1px solid $grey; + border-radius: 50%; + cursor: pointer; + display: block; + margin: 1rem auto 0; + height: 1rem; + width: 1rem; + } + + input[type=radio]:checked + label { + background: $blue; + border: 1px solid $blue; + box-shadow: inset 0 0 0 0.15rem $white; + } +} diff --git a/ui/app/styles/components/codemirror.scss b/ui/app/styles/components/codemirror.scss new file mode 100644 index 000000000..37f773a04 --- /dev/null +++ b/ui/app/styles/components/codemirror.scss @@ -0,0 +1,173 @@ +$light-grey: #dde3e7; +$light-gray: #a4a4a4; +$light-grey-blue: #6c7b81; +$dark-grey: #788290; +$faded-gray: #eaeaea; +// Product colors +$atlas: #127eff; +$vagrant: #2f88f7; +$consul: #69499a; +$terraform: #822ff7; +$serf: #dd4e58; +$packer: #1ddba3; + +// Our colors +$gray: lighten($black, 89%); +$red: #ff3d3d; +$green: #39b54a; +$dark-gray: #535f73; + +$gutter-grey: #2a2f36; + +.CodeMirror-lint-tooltip { + background-color: #f9f9fa; + border: 1px solid $light-gray; + border-radius: 0; + color: lighten($black, 13%); + font-family: $family-monospace; + font-size: 13px; + padding: 7px 8px 9px; +} + +.cm-s-hashi { + &.CodeMirror { + background-color: $black !important; + color: #cfd2d1 !important; + border: none; + font-family: $family-monospace; + -webkit-font-smoothing: auto; + line-height: 1.4; + } + + .CodeMirror-gutters { + color: $dark-grey; + background-color: $gutter-grey; + border: none; + } + + .CodeMirror-cursor { + border-left: solid thin #f8f8f0; + } + + .CodeMirror-linenumber { + color: #6d8a88; + } + + &.CodeMirror-focused div.CodeMirror-selected { + background: rgba(255, 255, 255, 0.10); + } + + .CodeMirror-line::selection, + .CodeMirror-line > span::selection, + .CodeMirror-line > span > span::selection { + background: rgba(255, 255, 255, 0.10); + } + + .CodeMirror-line::-moz-selection, + .CodeMirror-line > span::-moz-selection, + .CodeMirror-line > span > span::-moz-selection { + background: rgba(255, 255, 255, 0.10); + } + + span.cm-comment { + color: $light-grey; + } + + span.cm-string, + span.cm-string-2 { + color: $packer; + } + + span.cm-number { + color: $serf; + } + + span.cm-variable { + color: lighten($consul, 20%); + } + + span.cm-variable-2 { + color: lighten($consul, 20%); + } + + span.cm-def { + color: $packer; + } + + span.cm-operator { + color: $gray; + } + span.cm-keyword { + color: $yellow; + } + + span.cm-atom { + color: $serf; + } + + span.cm-meta { + color: $packer; + } + + span.cm-tag { + color: $packer; + } + + span.cm-attribute { + color: #9fca56; + } + + span.cm-qualifier { + color: #9fca56; + } + + span.cm-property { + color: lighten($consul, 20%); + } + + span.cm-variable-3 { + color: #9fca56; + } + + span.cm-builtin { + color: #9fca56; + } + + .CodeMirror-activeline-background { + background: #101213; + } + + .CodeMirror-matchingbracket { + text-decoration: underline; + color: white !important; + } +} + +.readonly-codemirror { + .CodeMirror-cursors { + display: none; + } + + .cm-s-hashi { + span { + color: $light-grey; + } + + span.cm-string, + span.cm-string-2 { + color: $faded-gray; + } + + span.cm-number { + color: lighten($dark-gray, 30%); + } + + span.cm-property { + color: white; + } + + span.cm-variable-2 { + color: $light-grey-blue; + } + } +} diff --git a/ui/app/styles/components/confirm.scss b/ui/app/styles/components/confirm.scss new file mode 100644 index 000000000..d18a91f58 --- /dev/null +++ b/ui/app/styles/components/confirm.scss @@ -0,0 +1,23 @@ +.confirm-action > span { + @include from($tablet) { + align-items: center; + display: flex; + } + + * { + margin-left: $size-8; + } + + .confirm-action-text:not('.is-block') { + text-align: right; + + @include until($tablet) { + display: block; + margin-bottom: $size-8; + text-align: left; + } + } + .confirm-action-text.is-block { + text-align: left; + } +} diff --git a/ui/app/styles/components/form-section.scss b/ui/app/styles/components/form-section.scss new file mode 100644 index 000000000..721bd04d5 --- /dev/null +++ b/ui/app/styles/components/form-section.scss @@ -0,0 +1,9 @@ +.form-section { + padding-top: 1.75rem; + box-shadow: 0 -1px 0 0 rgba($black, 0.1); +} + +.field:first-child .form-section { + padding: 0; + box-shadow: none; +} diff --git a/ui/app/styles/components/global-flash.scss b/ui/app/styles/components/global-flash.scss new file mode 100644 index 000000000..d1c059778 --- /dev/null +++ b/ui/app/styles/components/global-flash.scss @@ -0,0 +1,24 @@ +.global-flash { + position: fixed; + @include until($desktop) { + position: -webkit-sticky; + position: sticky; + top: 0; + bottom: auto; + margin: 0 auto; + width: 95%; + } + width: 450px; + bottom: 0; + left: 0; + margin: 10px; + z-index: 1; + + .notification { + box-shadow: 0 0 25px rgba($black, 0.2); + margin: 20px; + @include until($desktop) { + margin: 1rem 0; + } + } +} diff --git a/ui/app/styles/components/info-table-row.scss b/ui/app/styles/components/info-table-row.scss new file mode 100644 index 000000000..73a0762d1 --- /dev/null +++ b/ui/app/styles/components/info-table-row.scss @@ -0,0 +1,57 @@ +.info-table-row { + box-shadow: 0 1px 0 $grey-light; + margin: 0 $size-6; + + @include from($tablet) { + display: flex; + } + + &.thead { + box-shadow: 0 1px 0 $grey-light, 0 -1px 0 $grey-light; + margin: 0; + padding: 0 $size-6; + + .column { + padding: 0.5rem 0.75rem; + } + } + + .column { + &.info-table-row-edit { + padding-bottom: 0.3rem; + padding-top: 0.3rem; + } + + @include until($tablet) { + padding: 0; + } + + &:first-child { + padding-left: 0; + + @include until($tablet) { + padding: $size-8 0 0; + } + } + + &:last-child { + padding-right: 0; + + @include until($tablet) { + padding: 0 0 $size-8; + } + } + + textarea { + min-height: 35px; + } + } +} + +.info-table-row-header { + margin: 0; + + @include until($tablet) { + display: none; + } +} diff --git a/ui/app/styles/components/init-illustration.scss b/ui/app/styles/components/init-illustration.scss new file mode 100644 index 000000000..5cc72e28e --- /dev/null +++ b/ui/app/styles/components/init-illustration.scss @@ -0,0 +1,5 @@ +.init-illustration { + position: absolute; + left: calc(50% - 100px); + top: -94px; +} diff --git a/ui/app/styles/components/input-hint.scss b/ui/app/styles/components/input-hint.scss new file mode 100644 index 000000000..6e9e688e2 --- /dev/null +++ b/ui/app/styles/components/input-hint.scss @@ -0,0 +1,5 @@ +.input-hint { + padding: 0 $size-9; + font-size: $size-8; + color: $grey; +} diff --git a/ui/app/styles/components/linked-block.scss b/ui/app/styles/components/linked-block.scss new file mode 100644 index 000000000..dfa6d7c8d --- /dev/null +++ b/ui/app/styles/components/linked-block.scss @@ -0,0 +1,16 @@ +.linked-block { + cursor: pointer; + &:hover, + &:focus { + position: relative; + box-shadow: $box-link-hover-shadow; + } + &:active { + position: relative; + box-shadow: $box-link-active-shadow; + } +} + +.linked-block .columns { + @extend .is-flex-center; +} diff --git a/ui/app/styles/components/list-pagination.scss b/ui/app/styles/components/list-pagination.scss new file mode 100644 index 000000000..c012d7e41 --- /dev/null +++ b/ui/app/styles/components/list-pagination.scss @@ -0,0 +1,78 @@ +.list-pagination { + @extend .has-slim-padding; + position: relative; + top: 1px; + background-color: $grey-lighter; + margin-bottom: $size-4; + + a { + text-decoration: none; + height: 1.5rem; + min-width: 1.5rem; + border: none; + } + a.pagination-link { + width: 3ch; + } + a:not('.is-current'):hover { + text-decoration: underline; + color: $blue; + } + a.is-current { + background-color: $grey; + } + .pagination { + justify-content: center; + } + .pagination-list { + flex-grow: 0; + } + .pagination-ellipsis { + margin: 0; + padding-left: 0; + padding-right: 0; + } +} +.list-pagination .pagination-previous, +.list-pagination .pagination-next { + @extend .button; + @extend .is-primary; + @extend .is-outlined; + @extend .is-compact; + background: $white; + max-width: 8rem; + + @include until($tablet) { + max-width: 2rem; + padding-left: 0; + padding-right: 0; + } + + .pagination-next-label, + .pagination-previous-label { + @include until($tablet) { + display: none; + } + } + + .icon { + height: 1em; + width: 1em; + vertical-align: middle; + + &:last-child:not(:first-child), + &:first-child:not(:last-child) { + margin: -0.1em 0 0; + } + } + + .button .icon { + margin: 0; + } +} + +.list-pagination .pagination-next { + @include until($tablet) { + order: 3; + } +} diff --git a/ui/app/styles/components/loader.scss b/ui/app/styles/components/loader.scss new file mode 100644 index 000000000..df2121b17 --- /dev/null +++ b/ui/app/styles/components/loader.scss @@ -0,0 +1,3 @@ +.loader-inner-page { + height: 60vh; +} diff --git a/ui/app/styles/components/message-in-page.scss b/ui/app/styles/components/message-in-page.scss new file mode 100644 index 000000000..936e2273e --- /dev/null +++ b/ui/app/styles/components/message-in-page.scss @@ -0,0 +1,10 @@ +.message-in-page { + margin-bottom: 2rem; + position: relative; + + .close-button { + position: absolute; + right: 1rem; + top: $size-10; + } +} diff --git a/ui/app/styles/components/page-header.scss b/ui/app/styles/components/page-header.scss new file mode 100644 index 000000000..5557130a2 --- /dev/null +++ b/ui/app/styles/components/page-header.scss @@ -0,0 +1,24 @@ +.page-header { + padding-bottom: $size-10; + padding-top: $size-4; + + .level { + align-items: flex-end; + } + .level-left, + .level-right { + flex-grow: 1; + flex-shrink: 1; + } + .level-right { + justify-content: flex-end; + } + + .title { + margin-top: $size-1; + } + + .breadcrumb + .level .title { + margin-top: $size-4; + } +} diff --git a/ui/app/styles/components/popup-menu.scss b/ui/app/styles/components/popup-menu.scss new file mode 100644 index 000000000..8e2406737 --- /dev/null +++ b/ui/app/styles/components/popup-menu.scss @@ -0,0 +1,75 @@ +.popup-menu-content { + border-radius: 2px; + margin: -2px 0 0 0; + & > .box { + border-radius: 2px; + padding: 0; + position: relative; + width: 175px; + @include css-top-arrow(8px, $white, 1px, $blue, 155px); + } + &.is-wide > .box { + width: 200px; + @include css-top-arrow(8px, $white, 1px, $blue, 178px); + } + + .confirm-action span .button { + display: block; + margin: .25rem auto; + width: 95%; + } +} +.popup-menu-trigger { + width: 3rem; + height: 2rem; +} +.popup-menu-trigger.is-active { + &, + &:active, + &:focus { + box-shadow: 0 0 0 1px $blue; + } +} + +.ember-basic-dropdown-content--left.popup-menu { + margin: 0px 0 0 -8px; +} + +.popup-menu-content .menu { + button.link, + a { + border-radius: $menu-item-radius; + color: $menu-item-color; + font-size: $size-7; + font-weight: $font-weight-semibold; + display: block; + padding: $size-9 $size-6; + box-shadow: none; + border: none; + background: transparent; + height: auto; + width: 100%; + text-align: left; + + &:hover { + background-color: $menu-item-hover-background-color; + color: $menu-item-hover-color; + } + + &.is-active { + background-color: $menu-item-active-background-color; + color: $menu-item-active-color; + } + } +} +.popup-menu-content .menu-list { + margin: 0.1rem; +} +.popup-menu-content .menu-label { + background: $grey-lighter; + font-size: $size-8; + letter-spacing: 0; + margin: 0; + padding: $size-10 calc(#{$size-6} + .1rem); + text-transform: none; +} diff --git a/ui/app/styles/components/role-item.scss b/ui/app/styles/components/role-item.scss new file mode 100644 index 000000000..9663deba0 --- /dev/null +++ b/ui/app/styles/components/role-item.scss @@ -0,0 +1,4 @@ +.role-item-details { + float: left; + margin-left: 8px; +} diff --git a/ui/app/styles/components/shamir-progress.scss b/ui/app/styles/components/shamir-progress.scss new file mode 100644 index 000000000..4bb418328 --- /dev/null +++ b/ui/app/styles/components/shamir-progress.scss @@ -0,0 +1,11 @@ +.shamir-progress { + .shamir-progress-progress { + display: inline-block; + margin-right: $size-8; + } + .progress { + box-shadow: 0 0 0 4px $progress-bar-background-color; + display: inline; + width: 150px; + } +} diff --git a/ui/app/styles/components/sidebar.scss b/ui/app/styles/components/sidebar.scss new file mode 100644 index 000000000..5e6e21353 --- /dev/null +++ b/ui/app/styles/components/sidebar.scss @@ -0,0 +1,85 @@ +.is-sidebar { + border-right: $base-border; + display: flex; + flex: 1; + margin: 0.75rem 0.75rem 0.75rem 0; + padding: 0 0 0 0.75rem; + + @include until($tablet) { + background-color: $white; + bottom: 0; + left: -1.5rem; + margin: 0; + max-width: 300px; + padding-left: 0; + position: absolute; + right: $size-2; + transform: translateX(-100%); + transition: transform $speed; + top: -3rem; + z-index: 5; + } + + &.is-active { + @include until($tablet) { + transform: translateX(0); + } + } + + .menu-toggle { + color: $blue; + cursor: pointer; + display: none; + + @include until($tablet) { + display: block; + margin-left: $size-8; + position: absolute; + top: 4.2rem; + left: 100%; + } + + .button { + min-width: 0; + } + } + + .menu { + flex: 1; + padding-top: 5.25rem; + position: relative; + + @include until($tablet) { + padding-top: $size-6; + } + } + + .menu-label { + color: $grey-light; + font-size: $size-small; + line-height: 1; + margin-bottom: $size-8; + padding-left: $size-5; + } + + .menu-list { + border-top: $base-border; + padding: $size-9 0; + + a { + color: $grey-dark; + padding-left: $size-5; + transition: 250ms border-width; + + &.is-active { + color: $blue; + background-color: $dark-white; + border-right: 4px solid $blue; + } + + &:hover { + background-color: $dark-white; + } + } + } +} diff --git a/ui/app/styles/components/status-menu.scss b/ui/app/styles/components/status-menu.scss new file mode 100644 index 000000000..c299bcd51 --- /dev/null +++ b/ui/app/styles/components/status-menu.scss @@ -0,0 +1,104 @@ +.status-menu-content { + max-width: 360px; + min-width: 280px; + border-radius: 3px; + margin: 0 -17px 0 0; + will-change: transform, opacity; + + .card { + position: relative; + border-radius: 2px; + border: $base-border; + box-shadow: 0 0 4px rgba($black, 0.21); + color: $black; + @include css-top-arrow(8px, $grey-lighter, 1px, $grey, 100%, -25px); + .card-content { + padding: $size-6 $size-3; + } + } + + .card-header-title, + .menu-label { + background: $grey-lighter; + color: $grey-dark; + font-size: $size-7; + font-weight: normal; + letter-spacing: 0; + margin: 0; + padding: 8px $size-3; + text-transform: uppercase; + } + + .replication-card { + @include css-top-arrow(8px, $grey-lighter, 1px, $grey, 100%, -98px); + .box-label { + box-shadow: none; + } + a.is-active, + a:hover { + box-shadow: 0 0 0 1px $blue; + } + } + + &.ember-basic-dropdown-content--below.ember-basic-dropdown--transitioning-in { + animation: drop-fade-above .15s; + } + &.ember-basic-dropdown-content--below.ember-basic-dropdown--transitioning-out { + animation: drop-fade-above .15s reverse; + } + &.ember-basic-dropdown-content--above.ember-basic-dropdown--transitioning-in { + animation: drop-fade-below .15s; + } + &.ember-basic-dropdown-content--above.ember-basic-dropdown--transitioning-out { + animation: drop-fade-below .15s reverse; + } +} +.status-menu-content-replication { + margin-top: 5px; +} + +.is-status-chevron { + line-height: 0; + padding: 0.25em 0 0.25em 0.25em; +} + +.status-menu-user-trigger { + height: 1.25em; + width: 1.25em; + background: $white-bis; + box-shadow: 0 0 0 2px $dark-grey, 0 0 0 3px $grey-light; + margin-right: 0.25rem; + + .icon { + min-width: 0; + } +} + +.status-menu-content .menu { + button.link, + a { + font-size: $size-6; + font-weight: 500; + padding: $size-9 $size-3; + border-radius: $menu-item-radius; + color: $menu-item-color; + display: block; + padding: $size-9 $size-3; + box-shadow: none; + border: none; + background: transparent; + height: auto; + width: 100%; + text-align: left; + + &:hover { + background-color: $menu-item-hover-background-color; + color: $menu-item-hover-color; + } + + &.is-active { + background-color: $menu-item-active-background-color; + color: $menu-item-active-color; + } + } +} diff --git a/ui/app/styles/components/sub-nav.scss b/ui/app/styles/components/sub-nav.scss new file mode 100644 index 000000000..5c2f4447e --- /dev/null +++ b/ui/app/styles/components/sub-nav.scss @@ -0,0 +1,31 @@ +.sub-nav { + &.tabs { + background: $grey-lighter; + padding: 0 1.25rem; + ul { + border-color: transparent; + } + a { + color: $grey-dark; + font-weight: $font-weight-semibold; + text-decoration: none; + padding: 1.5rem 1rem; + border-bottom: 2px solid transparent; + transition: border-color $speed; + } + a:hover, + a:active { + border-color: $grey-light; + } + li:focus { + box-shadow: none; + } + li.is-active a { + border-color: $blue; + color: $blue; + } + .ember-basic-dropdown-trigger { + outline: none; + } + } +} diff --git a/ui/app/styles/components/tool-tip.scss b/ui/app/styles/components/tool-tip.scss new file mode 100644 index 000000000..a971e6e9b --- /dev/null +++ b/ui/app/styles/components/tool-tip.scss @@ -0,0 +1,47 @@ +.tool-tip { + font-size: $size-7; + text-transform: none; + margin: 8px 0px 0 -4px; + .box { + position: relative; + color: $white; + width: 200px; + background: $grey; + padding: 0.5rem; + line-height: 1.4; + } + @include css-top-arrow(8px, $grey, 1px, $grey-dark, 20px); + &.ember-basic-dropdown-content--below.ember-basic-dropdown--transitioning-in { + animation: drop-fade-above .15s; + } + &.ember-basic-dropdown-content--below.ember-basic-dropdown--transitioning-out { + animation: drop-fade-above .15s reverse; + } + &.ember-basic-dropdown-content--above.ember-basic-dropdown--transitioning-in { + animation: drop-fade-below .15s; + } + &.ember-basic-dropdown-content--above.ember-basic-dropdown--transitioning-out { + animation: drop-fade-below .15s reverse; + } +} + +.ember-basic-dropdown-content--left.tool-tip { + margin: 8px 0 0 -11px; +} +.tool-tip-trigger { + border: none; + border-radius: 20px; + height: 18px; + width: 18px; + outline: none; + box-shadow: none; + cursor: pointer; + padding: 0; + olor: $grey-dark; + margin-left: 8px; +} + +.b-checkbox .tool-tip-trigger { + position: relative; + top: -3px; +} diff --git a/ui/app/styles/components/upgrade-overlay.scss b/ui/app/styles/components/upgrade-overlay.scss new file mode 100644 index 000000000..5ec71408e --- /dev/null +++ b/ui/app/styles/components/upgrade-overlay.scss @@ -0,0 +1,59 @@ +.upgrade-overlay { + font-size: 1rem; + opacity: 0; + text-align: left; + transition: opacity $speed-slow; + will-change: opacity; + + &.is-animated { + opacity: 1; + } + + .modal-background { + background-image: url("/ui/vault-hex.svg"), linear-gradient(90deg, #191A1C, #1B212D); + opacity: 0.97; + } + + .modal-content { + overflow: auto; + overflow-x: hidden; + transform: translateY(20%) scale(0.9); + transition: transform $speed-slow; + will-change: transform; + } + + &.is-animated { + .modal-content { + transform: translateY(0) scale(1); + } + } + + .upgrade-overlay-title { + border-bottom: 1px solid $grey; + padding-bottom: $size-10; + } + + .upgrade-overlay-icon svg { + height: 24px; + width: auto; + } + + .columns { + margin-bottom: $size-4; + margin-top: $size-4; + } + + .column { + display: flex; + + .box { + border-radius: $radius; + box-shadow: inset 0 0 0 1px $grey; + width: 100%; + } + } + + li { + list-style: inside disc; + } +} diff --git a/ui/app/styles/core.scss b/ui/app/styles/core.scss new file mode 100644 index 000000000..405ed24b4 --- /dev/null +++ b/ui/app/styles/core.scss @@ -0,0 +1,66 @@ +// Start with Bulma variables as a foundation +@import "bulma/sass/utilities/initial-variables"; + +// Override variables where appropriate +@import "./utils/bulma_variables"; + +// Utils +@import "./utils/mixins"; +@import "./utils/animations"; + +// Bring in the rest of Bulma +@import "bulma/bulma"; +@import "bulma/switch"; +@import "bulma/bulma-radio-checkbox"; + +// Override Bulma details where appropriate +@import "./core/generic"; +@import "./core/box"; +@import "./core/breadcrumb"; +@import "./core/bulma-radio-checkboxes"; +@import "./core/buttons"; +@import "./core/footer"; +@import "./core/forms"; +@import "./core/helpers"; +@import "./core/hero"; +@import "./core/level"; +@import "./core/menu"; +@import "./core/message"; +@import "./core/navbar"; +@import "./core/notification"; +@import "./core/progress"; +@import "./core/switch"; +@import "./core/tables"; +@import "./core/tabs"; +@import "./core/tags"; +@import "./core/title"; + +// bulma additions +@import "./core/gradients"; +@import "./core/layout"; +@import "./core/lists"; + +@import "./components/auth-form"; +@import "./components/badge"; +@import "./components/b64-toggle"; +@import "./components/box-label"; +@import "./components/codemirror"; +@import "./components/confirm"; +@import "./components/form-section"; +@import "./components/global-flash"; +@import "./components/init-illustration"; +@import "./components/info-table-row"; +@import "./components/input-hint"; +@import "./components/linked-block"; +@import "./components/list-pagination"; +@import "./components/loader"; +@import "./components/message-in-page"; +@import "./components/page-header"; +@import "./components/popup-menu"; +@import "./components/role-item"; +@import "./components/shamir-progress"; +@import "./components/sidebar"; +@import "./components/status-menu"; +@import "./components/sub-nav"; +@import "./components/tool-tip"; +@import "./components/upgrade-overlay"; diff --git a/ui/app/styles/core/box.scss b/ui/app/styles/core/box.scss new file mode 100644 index 000000000..14cc4f45a --- /dev/null +++ b/ui/app/styles/core/box.scss @@ -0,0 +1,23 @@ +.box { + box-shadow: 0 0 0 1px rgba($grey-dark, 0.3); +} +.box.is-fullwidth { + padding-left: 0; + padding-right: 0; +} +.box.no-padding-bottom { + padding-bottom: 0; +} +.box.has-slim-padding { + padding: 9px 0; +} +.box.has-glow { + box-shadow: 0 2px 4px 0 rgba($grey-dark, 0.5); +} +.box.is-rounded { + border-radius: 3px; +} + +.box.no-top-shadow { + box-shadow: inset 0 -1px 0 0 rgba($black, 0.1); +} diff --git a/ui/app/styles/core/breadcrumb.scss b/ui/app/styles/core/breadcrumb.scss new file mode 100644 index 000000000..fde1d5906 --- /dev/null +++ b/ui/app/styles/core/breadcrumb.scss @@ -0,0 +1,56 @@ +.breadcrumb { + -ms-user-select: text; + -webkit-user-select: text; + user-select: text; + height: 1.5rem; + margin: 0; + overflow-x: auto; + + &:not(:last-child) { + margin: 0; + } + + .is-sidebar + .column & { + @include until($tablet) { + margin-left: $size-2; + } + } + + li { + & + li::before { + display: none; + } + + &:first-child { + .sep { + margin-left: 0; + } + } + } + + a { + line-height: 1; + padding: 0 $size-11 0 0; + text-decoration: none; + + &:hover { + color: $blue; + } + } + + .sep { + display: inline-block; + color: transparent; + margin: 0.15rem 0.4rem 0 0.5rem; + overflow: hidden; + width: 0.5rem; + + &::before { + color: $blue; + content: "❮"; + font-size: 1rem; + line-height: 1; + opacity: 0.33; + } + } +} diff --git a/ui/app/styles/core/bulma-radio-checkboxes.scss b/ui/app/styles/core/bulma-radio-checkboxes.scss new file mode 100644 index 000000000..8d7f944cb --- /dev/null +++ b/ui/app/styles/core/bulma-radio-checkboxes.scss @@ -0,0 +1,15 @@ +.b-checkbox input[type="checkbox"]:checked + label::before { + border-color: $blue; +} + +.b-checkbox input[type="checkbox"]:checked + label::after, +.b-checkbox input[type="radio"]:checked + label::after { + font-family: $family-monospace; + /*checkmark from ionicons*/ + content: url('data:image/svg+xml; utf8, '); +} + +.b-checkbox.no-label input[type="checkbox"] + label { + position: absolute; + top: 0; +} diff --git a/ui/app/styles/core/buttons.scss b/ui/app/styles/core/buttons.scss new file mode 100644 index 000000000..ef23caf78 --- /dev/null +++ b/ui/app/styles/core/buttons.scss @@ -0,0 +1,187 @@ +$button-box-shadow-standard: 0 3px 1px 0 rgba($black, 0.12); + +.button { + box-shadow: $button-box-shadow-standard; + border: 1px solid $grey-light; + color: $grey-dark; + display: inline-block; + font-size: $size-small; + font-weight: $font-weight-bold; + height: 2.5rem; + line-height: 1.6; + min-width: 6rem; + padding: $size-10 $size-8; + text-decoration: none; + transition: background-color $speed, border-color $speed, box-shadow $speed, color $speed; + vertical-align: middle; + + &.is-icon { + padding: 0.25rem $size-3; + } + + &:active, + &.is-active, + &:focus, + &.is-focused { + border-color: darken($grey-light, 10%); + box-shadow: $button-box-shadow-standard; + color: darken($grey-dark, 10%); + } + + &.is-inverted.is-outlined { + box-shadow: none; + } + + &.is-transparent { + color: currentColor; + background: none; + border: none; + box-shadow: none; + min-width: auto; + padding: 0; + } + + @each $name, $pair in $colors { + $color: nth($pair, 1); + @if $name == "primary" { + $color: $blue; + } + $color-invert: nth($pair, 2); + + &.is-#{$name} { + border-color: $color; + background-color: $color; + color: $color-invert; + + &:hover, + &.is-hovered { + background-color: darken($color, 5%); + border-color: darken($color, 5%); + } + + &:active, + &.is-active { + background-color: darken($color, 10%); + border-color: darken($color, 10%); + box-shadow: none; + } + + &:focus, + &.is-focused { + border-color: darken($color, 10%); + box-shadow: $button-box-shadow-standard; + } + + &.is-outlined { + border-color: $color; + color: $color; + background-color: transparent; + + &.is-important { + border-color: $color; + } + + &:hover, + &.is-hovered, + &:focus, + &.is-focused { + background-color: transparent; + border-color: darken($color, 10%); + color: $color; + } + + &:active, + &.is-active { + background-color: transparent; + border-color: darken($color, 10%); + color: darken($color, 10%); + } + } + + &.is-inverted.is-outlined { + border-color: rgba($color-invert, 0.5); + color: rgba($color-invert, 0.9); + + &:hover, + &.is-hovered, + &:focus, + &.is-focused { + background-color: transparent; + border-color: $color-invert; + color: $color-invert; + } + + &:active, + &.is-active { + background-color: rgba($color-invert, 0.2); + border-color: $color-invert; + color: $color-invert; + box-shadow: none; + } + } + } + } + + &.is-ghost { + background-color: transparent; + border-color: transparent; + box-shadow: none; + color: $blue; + + &:hover { + background-color: $grey-lighter; + } + } + + &.is-compact { + height: 2rem; + padding: $size-11 $size-8; + } + + .has-text-info & { + font-weight: $font-weight-semibold; + + .icon { + vertical-align: middle; + } + } + + &.is-more-icon, + &.tool-tip-trigger { + color: $black; + min-width: auto; + } + + &.has-icon-left, + &.has-icon-right { + .icon { + height: 16px; + min-width: auto; + width: 16px; + } + } + + &.has-icon-left { + .icon { + &, + &:first-child:last-child { + margin-left: -$size-10; + } + } + } + + &.has-icon-right { + .icon { + &, + &:first-child:last-child { + margin-left: $size-11; + margin-right: -$size-10; + } + } + } +} + +.button .icon.auto-width { + width: auto; + margin: 0 !important; +} diff --git a/ui/app/styles/core/footer.scss b/ui/app/styles/core/footer.scss new file mode 100644 index 000000000..b176a8366 --- /dev/null +++ b/ui/app/styles/core/footer.scss @@ -0,0 +1,9 @@ +.footer { + border-top: $base-border; + padding: $size-3 1.5rem; + + span:not(:first-child) { + display: inline-block; + padding: 0 0.5rem; + } +} diff --git a/ui/app/styles/core/forms.scss b/ui/app/styles/core/forms.scss new file mode 100644 index 000000000..7fa28d5fd --- /dev/null +++ b/ui/app/styles/core/forms.scss @@ -0,0 +1,272 @@ +label { + cursor: pointer; + &.is-select-list { + padding: 10px 0px; + &:hover { + color: $blue; + } + } +} + +.label { + color: $grey-darker; + text-transform: uppercase; + font-size: $size-small; +} + +.is-label, +.b-checkbox .is-label { + color: $grey-darker; + display: inline-block; + font-size: $size-small; + font-weight: $font-weight-bold; + + &:not(:last-child) { + margin-bottom: 0.25rem; + } + // Sizes + &.is-small { + font-size: $size-small; + } + &.is-medium { + font-size: $size-medium; + } + &.is-large { + font-size: $size-large; + } + + &::before, + &::after { + transform: translateY(0.2em); + } + + &::before { + border-color: $grey-light; + } +} + +.b-checkbox .is-label { + display: inline; + margin-left: $size-10; +} + +.help { + &.is-danger { + font-weight: $weight-bold; + } +} + +.is-help { + font-size: $size-small; + margin-top: 0.25rem; + @each $name, $pair in $colors { + $color: nth($pair, 1); + &.is-#{$name} { + color: $color; + } + } +} + +.input, +.textarea, +.select select { + @include until($desktop) { + font-size: 16px; + } + &::placeholder { + opacity: 0.5; + } + border-color: $grey-light; + color: $grey-dark; + padding-left: $size-8; + padding-right: $size-8; + + .has-background-grey-lighter & { + background-color: $white; + } +} + +.input, +.select select, +.control.has-icons-left .icon, +.control.has-icons-right .icon { + height: 2.5rem; +} + +.input.variable { + font-family: $family-monospace; +} +.input[disabled], +.textarea[disabled] { + border-color: $grey-lighter; + background-color: $white-ter; + color: $grey-light; +} + +.control { + max-width: 100%; + + // Modifiers + &.has-icons-left, + &.has-icons-right { + .input, + .select select { + padding-left: 2rem; + + &:focus, + &.is-focused, + &:active, + &.is-active { + & ~ .icon { + color: currentColor; + } + } + } + } + &.has-checkbox-right { + label.checkbox { + display: inline-flex; + height: 2.5em; + position: absolute; + top: 0; + right: 0; + justify-content: flex-end; + margin-left: auto; + } + .input, + .select select { + padding-right: 10em; + } + } +} + +.input, +.textarea { + box-shadow: none; + + @each $name, $pair in $colors { + $color: nth($pair, 1); + $color-invert: findColorInvert($color); + $color-bg: nth($pair, 2); + &.is-#{$name} { + border-color: $color; + background-color: $color-bg; + &.is-inverted { + border-color: rgba($color-invert, 0.3); + background-color: $color-bg; + &::placeholder { + color: rgba($color-invert, 0.3); + } + &:focus, + &.is-focused, + &:active, + &.is-active { + border-color: rgba($color-invert, 1); + background-color: $color-bg; + color: $color-invert; + &::placeholder { + color: rgba($color-invert, 0.7); + } + } + } + } + } + &:focus, + &.is-focused, + &:active, + &.is-active { + background-color: $input-focus-background-color; + } +} + +.select { + &:not(.is-multiple)::after { + border-width: 2px; + margin-top: 0; + transform: rotate(-45deg) translateY(-50%); + } +} + +.field:not(:last-child) { + margin-bottom: 1.5rem; +} +.field-body .field { + margin-bottom: 0; +} +.field.has-addons { + flex-wrap: wrap; + .control { + .button, + .checkbox, + .input, + .select select { + border-radius: 0; + &:hover, + &.is-hovered { + z-index: 2; + } + &:focus, + &.is-focused, + &:active, + &.is-active { + z-index: 3; + &:hover { + z-index: 4; + } + } + } + &:first-of-type { + flex-grow: 1; + .button, + .checkbox, + .input, + .select select { + border-bottom-left-radius: $input-radius; + border-top-left-radius: $input-radius; + } + } + &:last-child { + .button, + .checkbox, + .input, + .select select { + border-bottom-right-radius: $input-radius; + border-top-right-radius: $input-radius; + } + } + } + & > .label { + flex-grow: 1; + flex-shrink: 0; + width: 100%; + } + .checkbox { + @include input; + background-color: $dark-white; + @each $name, $pair in $colors { + $color: nth($pair, 1); + &.is-#{$name} { + border-color: $color; + } + } + input { + margin-right: 0.5em; + } + } +} + +.file-icon, +.file-label { + color: $grey-dark; +} +.file-cta { + background: $white-bis; + position: relative; +} +.file-delete-button { + @extend .button; + @extend .is-transparent; + color: $grey; + position: absolute; + right: 5px; +} diff --git a/ui/app/styles/core/generic.scss b/ui/app/styles/core/generic.scss new file mode 100644 index 000000000..358b6dd32 --- /dev/null +++ b/ui/app/styles/core/generic.scss @@ -0,0 +1,45 @@ +// seriously ? yes. seriously. +a { + text-decoration: underline; + &:hover, + &:active, + &:focus { + position: relative; + } +} +.menu a { + text-decoration: none; +} +code { + padding: 0; + font-size: 1em; +} +.icon { + min-width: 1.5rem; + vertical-align: middle; +} +code, +pre { + font-smoothing: inherit; + -webkit-font-smoothing: inherit; +} + +input::-webkit-outer-spin-button, +input::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; +} + +.link { + background: transparent; + border: 0; + color: $blue; + cursor: pointer; + display: inline; + font: inherit; + line-height: normal; + margin: 0; + padding: 0; + text-decoration: underline; + -moz-user-select: text; +} diff --git a/ui/app/styles/core/gradients.scss b/ui/app/styles/core/gradients.scss new file mode 100644 index 000000000..56b409463 --- /dev/null +++ b/ui/app/styles/core/gradients.scss @@ -0,0 +1,3 @@ +.has-dark-grey-gradient { + background: linear-gradient(to right, darken($grey-dark, 25%), $grey-dark); +} diff --git a/ui/app/styles/core/helpers.scss b/ui/app/styles/core/helpers.scss new file mode 100644 index 000000000..57d7259f9 --- /dev/null +++ b/ui/app/styles/core/helpers.scss @@ -0,0 +1,134 @@ +.is-invisible { + visibility: hidden; +} +.is-underline { + text-decoration: underline; +} +.is-sideless { + box-shadow: 0 2px 0 -1px $grey-light, 0 -2px 0 -1px $grey-light; +} +.is-bottomless { + box-shadow: 0 -1px 0 0 $grey-light; +} +.is-borderless { + border: none !important; +} +.is-relative { + position: relative; +} +.is-fullwidth { + width: 100%; +} +.is-in-bottom-right { + position: absolute; + bottom: 1rem; + right: 1rem; + z-index: 10; +} + +.has-background-transition { + transition: background-color $easing $speed; +} +.is-flex-column { + display: flex; + flex-direction: column; +} +.is-flex-v-centered { + display: flex; + align-items: center; + align-self: center; + justify-content: center; +} +.is-flex-v-centered-tablet { + @include tablet { + display: flex; + align-items: center; + align-self: center; + justify-content: center; + } +} +.is-flex-center { + display: flex; + align-items: center; +} +.is-flex-1 { + flex: 1; +} +.is-flex-wrap { + flex-flow: row wrap; +} +.is-flex-end { + display: flex !important; + justify-content: flex-end; +} +.is-flex-full { + flex-basis: 100%; +} +.is-no-flex-grow { + flex-grow: 0 !important; +} +.is-auto-width { + width: auto; +} + +.is-flex-between, +.is-grouped-split { + display: flex; + justify-content: space-between !important; +} +.has-default-border { + border: 1px solid $grey !important; +} +.has-no-pointer { + pointer-events: none; +} +.has-pointer { + cursor: pointer; +} +.has-short-padding { + padding: 0.25rem 1.25rem; +} + +.is-sideless.has-short-padding { + padding: 0.25rem 1.25rem; +} +.has-current-color-fill { + &, + & svg { + fill: currentColor; + } +} +.is-word-break { + overflow-wrap: break-word; + word-wrap: break-word; + word-break: break-word; + white-space: pre-wrap; +} +.is-font-mono { + font-family: $family-monospace; +} +.is-size-8 { + font-size: $size-8 !important; +} +.is-size-9 { + font-size: $size-9 !important; +} +@each $name, $shade in $shades { + .has-background-#{$name} { + background: $shade !important; + } +} +.has-background-transparent { + background: transparent !important; +} +@each $name, $pair in $colors { + $color: nth($pair, 1); + .has-background-#{$name} { + background: $color !important; + } +} +.is-optional { + color: $grey; + font-size: $size-8; + text-transform: lowercase; +} diff --git a/ui/app/styles/core/hero.scss b/ui/app/styles/core/hero.scss new file mode 100644 index 000000000..bf040a64f --- /dev/null +++ b/ui/app/styles/core/hero.scss @@ -0,0 +1,7 @@ +.hero-body { + padding: 3rem 2rem 1rem; + + svg { + margin-bottom: $size-10; + } +} diff --git a/ui/app/styles/core/layout.scss b/ui/app/styles/core/layout.scss new file mode 100644 index 000000000..49b099327 --- /dev/null +++ b/ui/app/styles/core/layout.scss @@ -0,0 +1,29 @@ +.ember-application > .ember-view { + display: flex; + flex-direction: column; +} +.page-container { + min-height: 100vh; + display: flex; + flex-direction: column; + justify-content: flex-end; +} + +.section { + display: flex; + flex-grow: 1; + flex-direction: column; + padding-top: 0; + padding-bottom: 0; + + > .container { + display: flex; + flex-grow: 1; + flex-direction: column; + width: 100%; + + > .columns { + flex-grow: 1; + } + } +} diff --git a/ui/app/styles/core/level.scss b/ui/app/styles/core/level.scss new file mode 100644 index 000000000..ffe88fb49 --- /dev/null +++ b/ui/app/styles/core/level.scss @@ -0,0 +1,15 @@ +.level:not(:last-child) { + @include vault-block; +} + +.level.has-padded-items { + .level-item { + flex: 0 1 auto; + } + .level-item { + padding-right: 1.5rem; + } + .level-item.is-fixed-25 { + flex-basis: 25%; + } +} diff --git a/ui/app/styles/core/lists.scss b/ui/app/styles/core/lists.scss new file mode 100644 index 000000000..26e0da6a9 --- /dev/null +++ b/ui/app/styles/core/lists.scss @@ -0,0 +1,10 @@ +.sep { + display: inline-flex; + align-items: center; + &:before { + font-size: $size-5; + color: $white-ter; + content: '|'; + position: relative; + } +} diff --git a/ui/app/styles/core/menu.scss b/ui/app/styles/core/menu.scss new file mode 100644 index 000000000..81fb5de04 --- /dev/null +++ b/ui/app/styles/core/menu.scss @@ -0,0 +1,11 @@ +.column .menu-list a { + border-radius: 0; + border-right: 0 solid transparent; + font-weight: $font-weight-semibold; + + &:hover, + &.is-active { + color: $menu-item-hover-background-color; + background-color: $menu-item-active-color; + } +} diff --git a/ui/app/styles/core/message.scss b/ui/app/styles/core/message.scss new file mode 100644 index 000000000..f9007a535 --- /dev/null +++ b/ui/app/styles/core/message.scss @@ -0,0 +1,38 @@ +.message { + &.is-list { + margin: $size-10 0; + } + + &.is-warning { + .message-body { + color: $dark-yellow; + padding: 0.75rem 1.25rem; + } + } + + &.is-highlight { + background: $light-yellow; + .message-body { + border: none; + box-shadow: 0 0 0 1px $orange; + color: $dark-yellow; + } + .has-text-highlight, + .close-button { + color: $orange; + } + .title, + code { + background: none; + color: inherit; + } + .content .button { + border-color: $orange; + color: $dark-yellow; + } + } + + .content { + margin-bottom: 0; + } +} diff --git a/ui/app/styles/core/navbar.scss b/ui/app/styles/core/navbar.scss new file mode 100644 index 000000000..b750221ae --- /dev/null +++ b/ui/app/styles/core/navbar.scss @@ -0,0 +1,41 @@ +.navbar { + align-items: center; + height: 3.5rem; +} + +.navbar-end { + .navbar-item { + padding: 0.5em 0.75em; + + .button { + display: inline-flex; + min-width: 2.5rem; + } + } +} + +a.navbar-item { + color: $white-ter; + &:hover, + &:active { + color: $white; + background: transparent; + } +} + +.navbar-brand { + margin: 0 0 0 $size-10; +} + +.navbar-sections { + justify-content: flex-start; + background-color: $grey; + + @include from($tablet) { + background-color: transparent; + position: absolute; + left: 3.5em; + top: 0; + height: 3.25rem; + } +} diff --git a/ui/app/styles/core/notification.scss b/ui/app/styles/core/notification.scss new file mode 100644 index 000000000..44447c8cc --- /dev/null +++ b/ui/app/styles/core/notification.scss @@ -0,0 +1,58 @@ +.notification { + padding: 1rem 2.5rem 1rem 1.5rem; + .title { + font-weight: $weight-bold; + } + &.has-border { + border: 1px solid currentColor; + border-left-width: 10px; + } + + @each $name, $pair in $colors { + $color: nth($pair, 1); + $color-invert: nth($pair, 2); + $color-lightning: max((100% - lightness($color)) - 2%, 0%); + $color-luminance: colorLuminance($color); + $darken-percentage: $color-luminance * 70%; + $desaturate-percentage: $color-luminance * 30%; + &.is-#{$name} { + background-color: lighten($color, $color-lightning); + border-color: $color; + color: desaturate( + darken($color, $darken-percentage), + $desaturate-percentage + ); + .delete { + color: $color; + } + .title { + color: $color-invert; + margin-bottom: .5rem; + } + } + } + + &.is-warning { + .title, + .delete { + color: $dark-yellow; + } + border-color: $orange; + color: $dark-yellow; + } + + & > .delete { + &:before, + &:after { + content: none; + } + position: absolute; + background-color: transparent; + border: none; + color: currentColor; + right: 0.5rem; + top: 0.5rem; + height: 1.5rem; + width: 1.5rem; + } +} diff --git a/ui/app/styles/core/progress.scss b/ui/app/styles/core/progress.scss new file mode 100644 index 000000000..3454e73bd --- /dev/null +++ b/ui/app/styles/core/progress.scss @@ -0,0 +1,20 @@ +.progress[value]::-webkit-progress-bar, +.progress[value]::-webkit-progress-value { + border-radius: 2px; +} +.progress { + border-radius: 0; + margin-bottom: 0; + &.is-small { + height: 0.5rem; + } + &.is-narrow { + width: 30px; + } +} +.progress.is-rounded { + border-radius: 2px; +} +.progress.is-bordered { + box-shadow: 0 0 0 4px $progress-bar-background-color; +} diff --git a/ui/app/styles/core/switch.scss b/ui/app/styles/core/switch.scss new file mode 100644 index 000000000..12e30d4a8 --- /dev/null +++ b/ui/app/styles/core/switch.scss @@ -0,0 +1,27 @@ +.switch[type="checkbox"] { + &.is-small { + + label { + font-size: $size-9; + font-weight: bold; + padding-left: $size-9 * 2.5; + margin: 0 0.25rem; + &::before { + top: $size-9 / 5; + height: $size-9; + width: $size-9 * 2; + } + &::after { + width: $size-9 * 0.68; + height: $size-9 * 0.68; + left: .15rem; + top: $size-9 / 2.5; + } + } + &:checked + label::after { + left: ($size-9 * 2) - ($size-9 * 0.9); + } + } +} +.switch[type="checkbox"]:focus + label { + box-shadow: 0 0 1px $blue; +} diff --git a/ui/app/styles/core/tables.scss b/ui/app/styles/core/tables.scss new file mode 100644 index 000000000..d43e5ef9f --- /dev/null +++ b/ui/app/styles/core/tables.scss @@ -0,0 +1,41 @@ +.table { + thead, + .thead { + background: $grey-lighter; + box-shadow: 0 1px 0 0 $grey-light, 0 -1px 0 0 $grey-light; + + th, + .th { + text-transform: uppercase; + font-size: $size-8; + color: $grey-dark; + font-weight: normal; + padding: 0.5rem 1.5rem; + border-width: 0 0 1px 0; + border-color: $grey-light; + } + } + + .thead { + box-shadow: 0 -1px 0 0 $grey-light; + } + td, + .td { + font-size: $size-6; + color: $black; + padding: 1rem 1.5rem; + border-width: 0 0 1px 0; + border-color: $grey-light; + border-style: solid; + } + tr:hover { + background: rgba($grey-lighter, .4); + } +} +.table tbody tr:last-child td, +.table tbody tr:last-child th { + border-bottom-width: inherit; +} +.table.has-expanded-borders { + border-collapse: inherit; +} diff --git a/ui/app/styles/core/tabs.scss b/ui/app/styles/core/tabs.scss new file mode 100644 index 000000000..27b5d1c1f --- /dev/null +++ b/ui/app/styles/core/tabs.scss @@ -0,0 +1,48 @@ +.tabs.tabs-subnav { + align-items: center; + height: 3.5rem; + margin-bottom: 0; + padding: $size-10 $size-10; + + @include until($tablet) { + background-color: $grey; + flex: 0 0 100%; + height: 3rem; + } + + ul { + border-bottom: none; + } + + a, + .link { + align-items: center; + border-bottom: none; + color: $white-ter; + cursor: pointer; + display: inline-flex; + font-weight: $font-weight-semibold; + line-height: 1; + padding: $size-10 $size-6; + text-decoration: none; + transition: color $speed, background-color $speed; + + &:hover { + color: $white; + } + } + .sep:before { + position: relative; + left: -0.75em; + } + + .is-active a, + .is-active .link { + color: $white; + background: rgba($black, 0.25); + border-radius: 4px; + @include until($tablet) { + background: rgba($grey-dark, 0.75); + } + } +} diff --git a/ui/app/styles/core/tags.scss b/ui/app/styles/core/tags.scss new file mode 100644 index 000000000..99eb6be52 --- /dev/null +++ b/ui/app/styles/core/tags.scss @@ -0,0 +1,28 @@ +.tag:not(body) { + background-color: lighten($grey-light, 17%); + border-radius: 2px; + height: auto; + padding: 0 0.75em; + margin-right: 0.5rem; + font-weight: normal; + code { + color: $grey; + } +} +.tag.is-outlined { + border: 1px solid currentColor; +} +.tag.is-inverted { + border-color: $grey; + background: none; + code { + color: $grey-dark; + } +} +.tag.is-bold { + font-weight: bold; +} + +.tag.is-small { + height: auto; +} diff --git a/ui/app/styles/core/title.scss b/ui/app/styles/core/title.scss new file mode 100644 index 000000000..be110bb64 --- /dev/null +++ b/ui/app/styles/core/title.scss @@ -0,0 +1,13 @@ +.title:not(:last-child), +.subtitle:not(:last-child) { + margin-bottom: 1rem; +} + +.title { + font-weight: $font-weight-bold; + + > a { + color: $black; + text-decoration: none; + } +} diff --git a/ui/app/styles/utils/_bulma_variables.scss b/ui/app/styles/utils/_bulma_variables.scss new file mode 100644 index 000000000..5ee88490b --- /dev/null +++ b/ui/app/styles/utils/_bulma_variables.scss @@ -0,0 +1,83 @@ +$vault-grey: #6a7786; +// ui colors +$grey-light: #bbc4d1; +$grey-lighter: #f9f9f9; +$grey: #929dab; +$grey-dark: $vault-grey; +$blue: #1563ff; + +// +$orange: #f9bb2d; +$light-orange: rgb(255, 254, 218); +$light-yellow: #fffbee; +$dark-yellow: #614903; +$light-red: #fff5f8; +$dark-red: #c84034; +$light-blue: #e2eafa; +$dark-blue: #1563ff; +$light-blue: rgb(218, 234, 247); +$dark-green: #36ae40; +$light-green: rgb(244, 250, 236); +$light-white: #f9f8fe; +$dark-white: #f9f9f9; +$white-ter: rgba($white, .5); +$white-bis: $dark-white; + +// Primary colors +$success: $dark-green; +$danger: $dark-red; +$warning: $light-yellow; +$primary: $grey-dark; +$code: $grey-dark; +$code-background: transparent; +$info: $dark-blue; +$light: $grey-lighter; +$border: $grey-light; + +$hr-margin: 1rem 0; + +//typography +$family-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; +$family-primary: $family-sans; +$body-size: 14px; +$size-3: (24/14) + 0rem; +$size-7: (13/14) + 0rem; +$size-8: (12/14) + 0rem; +$size-9: 0.75rem; +$size-10: 0.5rem; +$size-11: 0.25rem; +$size-small: $size-8; +$font-weight-normal: 400; +$font-weight-semibold: 600; +$font-weight-bold: 700; + +//input +$input-background-color: $dark-white; +$input-focus-background-color: $white; +$input-border-color: $grey; + +$radius: 2px; + +//box +$box-radius: 0; +$box-shadow: 0 0 0 1px rgba($black, 0.1); + +$link: $blue; +$text: $black; + +$breadcrumb-item-color: $blue; +$breadcrumb-item-separator-color: rgba($blue, 0.5); +$breadcrumb-item-active-color: $black; + +$navbar-background-color: transparent; + +$menu-item-hover-background-color: $blue; +$menu-item-hover-color: $white; + +$progress-bar-background-color: lighten($grey-light, 15%); + +$base-border: 1px solid $grey-light; + +// animations +$speed: 150ms; +$speed-slow: $speed * 2; diff --git a/ui/app/styles/utils/animations.scss b/ui/app/styles/utils/animations.scss new file mode 100644 index 000000000..8c7c3b2d7 --- /dev/null +++ b/ui/app/styles/utils/animations.scss @@ -0,0 +1,36 @@ +@mixin keyframes($name) { + @-webkit-keyframes #{$name} { + @content; + } + @-moz-keyframes #{$name} { + @content; + } + @-ms-keyframes #{$name} { + @content; + } + @keyframes #{$name} { + @content; + } +} + +@include keyframes(drop-fade-below) { + 0% { + opacity: 0; + transform: translateY(-1rem); + } + 100% { + opacity: 1; + transform: translateY(0px); + } +} + +@include keyframes(drop-fade-above) { + 0% { + opacity: 0; + transform: translateY(1rem); + } + 100% { + opacity: 1; + transform: translateY(0px); + } +} diff --git a/ui/app/styles/utils/mixins.scss b/ui/app/styles/utils/mixins.scss new file mode 100644 index 000000000..97f3cc012 --- /dev/null +++ b/ui/app/styles/utils/mixins.scss @@ -0,0 +1,45 @@ +@mixin css-top-arrow($size, $color, $border-width, $border-color, $left: 50%, $left-offset: 0px) { + & { + border: 1px solid $border-color; + } + + &:after, + &:before { + bottom: 100%; + border: solid transparent; + content: " "; + height: 0; + width: 0; + position: absolute; + pointer-events: none; + } + + &:after { + border-color: rgba($color, 0); + border-bottom-color: $color; + border-width: $size; + left: calc(#{$left} + #{$left-offset}); + margin-left: -$size; + } + &:before { + border-color: rgba($border-color, 0); + border-bottom-color: $border-color; + border-width: $size + round(1.41421356 * $border-width); + left: calc(#{$left} + #{$left-offset}); + margin-left: -($size + round(1.41421356 * $border-width)); + } + + @at-root .ember-basic-dropdown-content--left#{&} { + &:after, + &:before { + left: auto; + right: calc(#{$left} + #{$left-offset} - #{$size}); + } + } +} + +@mixin vault-block { + &:not(:last-child) { + margin-bottom: (5/14) + 0rem; + } +} diff --git a/ui/app/templates/application.hbs b/ui/app/templates/application.hbs new file mode 100644 index 000000000..fb6884ce5 --- /dev/null +++ b/ui/app/templates/application.hbs @@ -0,0 +1,139 @@ +
+ {{#if showNav}} +
+ + +
+ {{/if}} +
+ {{#each flashMessages.queue as |flash|}} + {{#flash-message data-test-flash-message=true flash=flash as |component flash close|}} + {{#if flash.componentName}} + {{component flash.componentName content=flash.content}} + {{else}} +
+ {{get (message-types flash.type) "text"}} +
+ + {{flash.message}} + + + {{/if}} + {{/flash-message}} + {{/each}} +
+ {{#if showNav}} +
+
+ {{component + (if + (or + (is-after (now interval=1000) auth.tokenExpirationDate) + (and activeClusterName auth.currentToken) + ) + 'token-expire-warning' + null + ) + }} + {{#unless (and + activeClusterName + auth.currentToken + (is-after (now interval=1000) auth.tokenExpirationDate) + ) + }} + {{outlet}} + {{/unless}} +
+
+ {{else}} + {{outlet}} + {{/if}} + +
+
+
+ + {{i-con glyph="hashicorp" size=18 aria-label="HashiCorp logo" }} + + + © {{moment-format (now) "Y"}} HashiCorp, Inc. + + + Vault {{activeCluster.leaderNode.version}} + + {{#if (is-version "OSS")}} + + {{#upgrade-link linkClass="has-text-grey"}} + Upgrade to Vault Enterprise + {{/upgrade-link}} + + {{/if}} + + Documentation + +
+
+
+ {{#if (eq env "development") }} +
+
+ {{i-con glyph="wand" class="type-icon"}}Local Development +
+
+ {{/if}} + +
diff --git a/ui/app/templates/components/.gitkeep b/ui/app/templates/components/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/ui/app/templates/components/auth-config-form/config.hbs b/ui/app/templates/components/auth-config-form/config.hbs new file mode 100644 index 000000000..9271bb65a --- /dev/null +++ b/ui/app/templates/components/auth-config-form/config.hbs @@ -0,0 +1,17 @@ +
+
+ {{message-error model=model}} + {{#if model.attrs}} + {{#each model.attrs as |attr|}} + {{form-field data-test-field attr=attr model=model}} + {{/each}} + {{else if model.fieldGroups}} + {{form-field-groups model=model}} + {{/if}} +
+
+ +
+
diff --git a/ui/app/templates/components/auth-config-form/options.hbs b/ui/app/templates/components/auth-config-form/options.hbs new file mode 100644 index 000000000..cee9da999 --- /dev/null +++ b/ui/app/templates/components/auth-config-form/options.hbs @@ -0,0 +1,13 @@ +
+
+ {{message-error model=model}} + {{#each model.tuneAttrs as |attr|}} + {{form-field data-test-field attr=attr model=model}} + {{/each}} +
+
+ +
+
diff --git a/ui/app/templates/components/auth-form.hbs b/ui/app/templates/components/auth-form.hbs new file mode 100644 index 000000000..506a942ec --- /dev/null +++ b/ui/app/templates/components/auth-form.hbs @@ -0,0 +1,45 @@ + +
+ {{message-error errorMessage=error}} + {{component providerComponentName onSubmit=(action 'doSubmit') }} +
+ {{#unless (eq selectedAuthBackend.type "token")}} + {{toggle-button toggleTarget=this toggleAttr="useCustomPath"}} +
+ {{#if useCustomPath}} + +
+ +
+

+ If this backend was mounted using a non-default path, enter it here. +

+ {{/if}} +
+ {{/unless}} +
+
+
+ +
diff --git a/ui/app/templates/components/auth-form/git-hub.hbs b/ui/app/templates/components/auth-form/git-hub.hbs new file mode 100644 index 000000000..a87e98afc --- /dev/null +++ b/ui/app/templates/components/auth-form/git-hub.hbs @@ -0,0 +1,17 @@ +
+
+ +
+ {{input + type="password" + value=token + name="token" + id="token" + class="input" + }} +
+
+
diff --git a/ui/app/templates/components/auth-form/ldap.hbs b/ui/app/templates/components/auth-form/ldap.hbs new file mode 100644 index 000000000..1bcd301f2 --- /dev/null +++ b/ui/app/templates/components/auth-form/ldap.hbs @@ -0,0 +1 @@ +{{partial "partials/userpass-form"}} diff --git a/ui/app/templates/components/auth-form/okta.hbs b/ui/app/templates/components/auth-form/okta.hbs new file mode 100644 index 000000000..1bcd301f2 --- /dev/null +++ b/ui/app/templates/components/auth-form/okta.hbs @@ -0,0 +1 @@ +{{partial "partials/userpass-form"}} diff --git a/ui/app/templates/components/auth-form/token.hbs b/ui/app/templates/components/auth-form/token.hbs new file mode 100644 index 000000000..7f89628f9 --- /dev/null +++ b/ui/app/templates/components/auth-form/token.hbs @@ -0,0 +1,17 @@ +
+
+ +
+ {{input + type="password" + value=token + name="token" + class="input" + data-test-token=true + }} +
+
+
diff --git a/ui/app/templates/components/auth-form/userpass.hbs b/ui/app/templates/components/auth-form/userpass.hbs new file mode 100644 index 000000000..1bcd301f2 --- /dev/null +++ b/ui/app/templates/components/auth-form/userpass.hbs @@ -0,0 +1 @@ +{{partial "partials/userpass-form"}} diff --git a/ui/app/templates/components/auth-info.hbs b/ui/app/templates/components/auth-info.hbs new file mode 100644 index 000000000..80e0d4f9e --- /dev/null +++ b/ui/app/templates/components/auth-info.hbs @@ -0,0 +1,56 @@ +
+
+

+ {{auth.authData.displayName}} +

+
+ +
diff --git a/ui/app/templates/components/auth-method/configuration.hbs b/ui/app/templates/components/auth-method/configuration.hbs new file mode 100644 index 000000000..67cbae8c5 --- /dev/null +++ b/ui/app/templates/components/auth-method/configuration.hbs @@ -0,0 +1,9 @@ +
+ {{#each model.attrs as |attr|}} + {{#if (eq attr.type "object")}} + {{info-table-row alwaysRender=true label=(or attr.options.label (to-label attr.name)) value=(stringify (get model attr.name))}} + {{else}} + {{info-table-row alwaysRender=true label=(or attr.options.label (to-label attr.name)) value=(get model attr.name)}} + {{/if}} + {{/each}} +
diff --git a/ui/app/templates/components/b64-toggle.hbs b/ui/app/templates/components/b64-toggle.hbs new file mode 100644 index 000000000..c52f4f4ff --- /dev/null +++ b/ui/app/templates/components/b64-toggle.hbs @@ -0,0 +1,5 @@ +{{#if isBase64}} + Decode from base64 +{{else}} + Encode to base64 +{{/if}} diff --git a/ui/app/templates/components/backend-configure.hbs b/ui/app/templates/components/backend-configure.hbs new file mode 100644 index 000000000..e69de29bb diff --git a/ui/app/templates/components/config-pki-ca.hbs b/ui/app/templates/components/config-pki-ca.hbs new file mode 100644 index 000000000..ed61c6961 --- /dev/null +++ b/ui/app/templates/components/config-pki-ca.hbs @@ -0,0 +1,174 @@ +{{#if replaceCA}} + {{message-error model=model}} +

+ {{#if needsConfig}} + Configure CA Certificate + {{else}} + Replace CA Certificate + {{/if}} +

+ {{#if (or model.certificate model.csr)}} + {{#each model.attrs as |attr|}} + {{info-table-row data-test-table-row label=(capitalize (or attr.options.label (humanize (dasherize attr.name)))) value=(get model attr.name)}} + {{/each}} +
+
+ {{#copy-button + clipboardText=(or model.certificate model.csr) + class="button" + buttonType="button" + success=(action (set-flash-message (concat (if model.certificate "Certificate" "CSR") " copied!"))) + }} + Copy {{if model.certificate "Certificate" "CSR"}} + {{/copy-button}} +
+
+ +
+
+ {{else}} +
+ {{#if model.uploadPemBundle}} + {{#message-in-page type="warning" data-test-warning=true}} + If you have already set a certificate and key, they will be overridden with the successful saving of a new PEM bundle. + {{/message-in-page}} + {{/if}} + {{partial "partials/form-field-groups-loop"}} +
+
+
+ +
+
+ +
+
+
+ {{#if model.canDeleteRoot}} + {{#confirm-action + buttonClasses="button" + onConfirmAction=(action "deleteCA") + confirmMessage="Are you sure you want to delete the root CA key?" + cancelButtonText="Cancel" + }} + Delete + {{/confirm-action}} + {{/if}} +
+
+
+ {{/if}} +{{else if signIntermediate}} + {{#if (or model.certificate)}} + {{#message-in-page data-test-warning type="warning"}} + If using this for an Intermediate CA in Vault, copy the certificate below and write it to the PKI mount being used as an intermediate using the `Set signed Intermediate` endpoint. + {{/message-in-page}} + {{#each model.attrs as |attr|}} + {{info-table-row data-test-table-row label=(capitalize (or attr.options.label (humanize (dasherize attr.name)))) value=(get model attr.name)}} + {{/each}} +
+
+ {{#copy-button + clipboardText=model.certificate + class="button" + buttonType="button" + success=(action (set-flash-message "Certificate copied!")) + }} + Copy Certificate + {{/copy-button}} +
+
+ +
+
+ {{else}} + {{message-error model=model}} +

Sign intermediate

+
+ {{partial "partials/form-field-groups-loop"}} +
+
+ +
+
+ +
+
+
+ {{/if}} +{{else if setSignedIntermediate}} + {{message-error model=model}} +

Set signed intermediate

+

+ Submit a signed CA certificate corresponding to a generated private key. +

+
+
+ +
+ {{textarea data-test-signed-intermediate class="textarea" id="certificate" name="certificate" value=model.certificate}} +
+
+
+
+ +
+
+ +
+
+
+{{else}} +

+ This is the default CA certificate used in Vault. It is not used for self-signed certificates or if you have a signed intermediate CA certificate with a generated key. +

+ {{#each downloadHrefs as |dl|}} + + {{/each}} + +
+
+ +
+ {{#if config.pem}} +
+ +
+ {{/if}} +
+ +
+
+{{/if}} diff --git a/ui/app/templates/components/config-pki.hbs b/ui/app/templates/components/config-pki.hbs new file mode 100644 index 000000000..c1f140429 --- /dev/null +++ b/ui/app/templates/components/config-pki.hbs @@ -0,0 +1,26 @@ +{{#if (eq section "tidy")}} +

+ You can tidy up the backend storage and/or CRL by removing certificates that have expired and are past a certain buffer period beyond their expiration time. +

+{{else if (eq section "crl")}} +

+ Certificate Revocation List (CRL) Config +

+

+ Set the duration for which the generated CRL should be marked valid. +

+{{/if}} + +{{message-error model=config}} +
+ {{#each (get config (concat section "Attrs")) as |attr|}} + {{form-field attr=attr model=config data-test-field=attr.name}} + {{/each}} +
+
+ +
+
+
diff --git a/ui/app/templates/components/edition-badge.hbs b/ui/app/templates/components/edition-badge.hbs new file mode 100644 index 000000000..0f8aac4ca --- /dev/null +++ b/ui/app/templates/components/edition-badge.hbs @@ -0,0 +1 @@ +{{abbreviation}} diff --git a/ui/app/templates/components/flex-table-column.hbs b/ui/app/templates/components/flex-table-column.hbs new file mode 100644 index 000000000..210430eda --- /dev/null +++ b/ui/app/templates/components/flex-table-column.hbs @@ -0,0 +1,14 @@ +
+
+ {{header}} +
+
+
+
+
+ + {{content}} + +
+
+
diff --git a/ui/app/templates/components/form-field-groups.hbs b/ui/app/templates/components/form-field-groups.hbs new file mode 100644 index 000000000..3fdc4ca44 --- /dev/null +++ b/ui/app/templates/components/form-field-groups.hbs @@ -0,0 +1,29 @@ +{{#each model.fieldGroups as |fieldGroup|}} + {{#each-in fieldGroup as |group fields|}} + {{#if (or (not renderGroup) (and renderGroup (eq group renderGroup)))}} + {{#if (eq group "default")}} + {{#each fields as |attr|}} + {{#unless (and (not-eq mode 'create') (eq attr.name "name"))}} + {{form-field data-test-field attr=attr model=model onChange=onChange}} + {{/unless}} + {{/each}} + {{else}} + {{toggle-button + class="is-block" + toggleAttr=(concat "show" (camelize group)) + toggleTarget=this + openLabel=(concat "Hide " group) + closedLabel=group + data-test-toggle-group=group + }} + {{#if (get this (concat "show" (camelize group)))}} +
+ {{#each fields as |attr|}} + {{form-field data-test-field attr=attr model=model}} + {{/each}} +
+ {{/if}} + {{/if}} + {{/if}} + {{/each-in}} +{{/each}} diff --git a/ui/app/templates/components/form-field.hbs b/ui/app/templates/components/form-field.hbs new file mode 100644 index 000000000..cbe64c422 --- /dev/null +++ b/ui/app/templates/components/form-field.hbs @@ -0,0 +1,114 @@ +{{#unless (or (and attr.options.editType (not-eq attr.options.editType "textarea")) (eq attr.type "boolean"))}} + +{{/unless}} +{{#if attr.options.possibleValues}} +
+
+ +
+
+{{else if (eq attr.options.editType 'mountAccessor')}} + {{mount-accessor-select + name=attr.name + label=labelString + warning=attr.options.warning + helpText=attr.options.helpText + value=(get model valuePath) + onChange=(action "setAndBroadcast" valuePath) + }} +{{else if (eq attr.options.editType 'kv')}} + {{kv-object-editor + value=(get model valuePath) + onChange=(action "setAndBroadcast" valuePath) + label=labelString + warning=attr.options.warning + helpText=attr.options.helpText + }} +{{else if (eq attr.options.editType 'file')}} + {{text-file + index='' + file=file + onChange=(action 'setFile') + warning=attr.options.warning + label=labelString + }} +{{else if (eq attr.options.editType 'ttl')}} + {{ttl-picker + initialValue=(or (get model valuePath) attr.options.defaultValue) + labelText=labelString + warning=attr.options.warning + setDefaultValue=false + onChange=(action (action "setAndBroadcast" valuePath)) + }} +{{else if (eq attr.options.editType 'stringArray')}} + {{string-list + label=labelString + warning=attr.options.warning + helpText=attr.options.helpText + inputValue=(get model valuePath) + onChange=(action (action "setAndBroadcast" valuePath)) + }} +{{else if (or (eq attr.type 'number') (eq attr.type 'string'))}} +
+ {{#if (eq attr.options.editType 'textarea')}} + + {{else}} + + {{/if}} +
+{{else if (eq attr.type 'boolean')}} +
+ + +
+{{else if (eq attr.type 'object')}} + {{json-editor + value=(if (get model valuePath) (stringify (get model valuePath)) emptyData) + valueUpdated=(action "codemirrorUpdated" attr.name) + }} +{{/if}} diff --git a/ui/app/templates/components/generate-credentials.hbs b/ui/app/templates/components/generate-credentials.hbs new file mode 100644 index 000000000..1253502c0 --- /dev/null +++ b/ui/app/templates/components/generate-credentials.hbs @@ -0,0 +1,133 @@ + + +{{#if (or options.generateWithoutInput (get model options.generatedAttr))}} + {{#if loading}} + {{partial "partials/loading"}} + {{else}} +
+ {{message-error model=model}} + {{#unless model.isError}} + {{#message-in-page type="warning" data-test-warning=true}} + You will not be able to access this information later, so please copy the information below. + {{/message-in-page}} + {{/unless}} + {{#each model.attrs as |attr|}} + {{#if (eq attr.type "object")}} + {{info-table-row label=(capitalize (or attr.options.label (humanize (dasherize attr.name)))) value=(stringify (get model attr.name))}} + {{else}} + {{info-table-row label=(capitalize (or attr.options.label (humanize (dasherize attr.name)))) value=(get model attr.name)}} + {{/if}} + {{/each}} +
+
+
+ {{#copy-button + clipboardText=model.toCreds + class="button is-primary" + buttonType="button" + success=(action (set-flash-message "Credentials copied!")) + }} + Copy credentials + {{/copy-button}} +
+ {{#if model.leaseId}} +
+ {{#copy-button + clipboardText=model.leaseId + class="button" + buttonType="button" + success=(action (set-flash-message "Lease ID copied!")) + }} + Copy Lease ID + {{/copy-button}} +
+ {{/if}} +
+ {{#if options.backIsListLink}} + {{#link-to + "vault.cluster.secrets.backend.list-root" + backend.id + data-test-secret-generate-back=true + class="button" + }} + Back + {{/link-to}} + {{else}} + + {{/if}} +
+
+ {{/if}} +{{else}} +
+
+ {{message-error model=model}} + {{#if model.fieldGroups}} + {{partial "partials/form-field-groups-loop"}} + {{else}} + {{#each model.attrs as |attr|}} + {{partial "partials/form-field-from-model"}} + {{/each}} + {{/if}} +
+
+
+ +
+
+ {{#link-to + "vault.cluster.secrets.backend.list-root" + backend.id + class="button" + data-test-secret-generate-cancel=true + }} + Cancel + {{/link-to}} +
+
+
+{{/if}} diff --git a/ui/app/templates/components/identity/edit-form.hbs b/ui/app/templates/components/identity/edit-form.hbs new file mode 100644 index 000000000..8ed98941f --- /dev/null +++ b/ui/app/templates/components/identity/edit-form.hbs @@ -0,0 +1,33 @@ +
+ {{message-error model=model}} +
+ {{#if (eq mode "merge")}} + {{#message-in-page type="warning"}} + Metadata on merged entities is not preserved, you will need to recreate it on the entity you merge to. + {{/message-in-page}} + {{/if}} + {{#each model.fields as |attr|}} + {{form-field data-test-field attr=attr model=model}} + {{/each}} +
+
+
+ + {{#if (or (eq mode "merge") (eq mode "create" ))}} + + Cancel + + {{else}} + + Cancel + + {{/if}} +
+
+
diff --git a/ui/app/templates/components/identity/entity-nav.hbs b/ui/app/templates/components/identity/entity-nav.hbs new file mode 100644 index 000000000..9d81b9d33 --- /dev/null +++ b/ui/app/templates/components/identity/entity-nav.hbs @@ -0,0 +1,44 @@ + +
+ +
+
+
+
+ {{identity/lookup-input type=identityType}} +
+
+
diff --git a/ui/app/templates/components/identity/item-alias/alias-details.hbs b/ui/app/templates/components/identity/item-alias/alias-details.hbs new file mode 100644 index 000000000..d7ecb17c5 --- /dev/null +++ b/ui/app/templates/components/identity/item-alias/alias-details.hbs @@ -0,0 +1,29 @@ +{{info-table-row label="Name" value=model.name }} +{{info-table-row label="ID" value=model.id }} +{{#info-table-row label=(if (eq model.identityType "entity-alias") "Entity ID" "Group ID") value=model.canonicalId}} + + {{model.canonicalId}} + +{{/info-table-row}} +{{info-table-row label="Merged from Entity ID" value=model.mergedFromCanonicalIds}} +{{#info-table-row label="Mount" value=model.mountAccessor }} +
+ {{model.mountPath}} +
+ {{model.mountType}} + {{model.mountAccessor}} +
+
+{{/info-table-row}} +{{#info-table-row label="Created" value=model.creationTime}} + +{{/info-table-row}} +{{#info-table-row label="Last Updated" value=model.lastUpdateTime}} + +{{/info-table-row}} diff --git a/ui/app/templates/components/identity/item-alias/alias-metadata.hbs b/ui/app/templates/components/identity/item-alias/alias-metadata.hbs new file mode 100644 index 000000000..efa6658b8 --- /dev/null +++ b/ui/app/templates/components/identity/item-alias/alias-metadata.hbs @@ -0,0 +1,26 @@ +{{#each-in model.metadata as |key value|}} +
+
+
+ {{key}} +
+
+ {{value}} +
+
+
+
+
+{{else}} +
+
+
+
+

+ There is no metadata associated with {{model.name}}. +

+
+
+
+
+{{/each-in}} diff --git a/ui/app/templates/components/identity/item-aliases.hbs b/ui/app/templates/components/identity/item-aliases.hbs new file mode 100644 index 000000000..f972272eb --- /dev/null +++ b/ui/app/templates/components/identity/item-aliases.hbs @@ -0,0 +1,36 @@ +{{#each (if model.alias.id (array model.alias) model.aliases) as |item|}} + {{#linked-block + "vault.cluster.access.identity.aliases.show" + item.id + "details" + class="box is-sideless is-marginless" + }} +
+ +
+
+
+ {{/linked-block}} +{{else}} +
+
+
+
+

+ There are no {{model.identityType}} aliases for {{model.name}}. +

+
+
+
+
+{{/each}} diff --git a/ui/app/templates/components/identity/item-details.hbs b/ui/app/templates/components/identity/item-details.hbs new file mode 100644 index 000000000..75ba63cd1 --- /dev/null +++ b/ui/app/templates/components/identity/item-details.hbs @@ -0,0 +1,22 @@ +{{info-table-row label="Name" value=model.name }} +{{info-table-row label="Type" value=model.type }} +{{info-table-row label="ID" value=model.id }} +{{#info-table-row label="Merged Ids" value=model.mergedEntityIds }} +
+ {{#each model.mergedEntityIds as |id|}} +
+ {{id}} +
+ {{/each}} +
+{{/info-table-row}} +{{#info-table-row label="Created" value=model.creationTime}} + +{{/info-table-row}} +{{#info-table-row label="Last Updated" value=model.lastUpdateTime}} + +{{/info-table-row}} diff --git a/ui/app/templates/components/identity/item-groups.hbs b/ui/app/templates/components/identity/item-groups.hbs new file mode 100644 index 000000000..149bf0ea2 --- /dev/null +++ b/ui/app/templates/components/identity/item-groups.hbs @@ -0,0 +1,38 @@ +{{#if model.groupIds}} + {{#each model.directGroupIds as |gid|}} + {{i-con + glyph='folder' + size=14 + class="has-text-grey-light" + }}{{gid}} + {{/each}} + {{#each model.inheritedGroupIds as |gid|}} + {{#linked-block + "vault.cluster.access.identity.show" "groups" gid "details" + class="box is-sideless is-marginless" + }} + {{i-con + glyph='folder' + size=14 + class="has-text-grey-light" + }}{{gid}} + inherited + {{/linked-block}} + {{/each}} +{{else}} +
+
+
+
+

+ {{model.name}} is not a member of any groups. +

+
+
+
+
+{{/if}} diff --git a/ui/app/templates/components/identity/item-members.hbs b/ui/app/templates/components/identity/item-members.hbs new file mode 100644 index 000000000..f31f16f62 --- /dev/null +++ b/ui/app/templates/components/identity/item-members.hbs @@ -0,0 +1,32 @@ +{{#if model.hasMembers}} + {{#each model.memberGroupIds as |gid|}} + {{i-con + glyph='folder' + size=14 + class="has-text-grey-light" + }}{{gid}} + {{/each}} + {{#each model.memberEntityIds as |gid|}} + {{i-con + glyph='role' + size=14 + class="has-text-grey-light" + }}{{gid}} + {{/each}} +{{else}} +
+
+
+
+

+ There are no members in this group. +

+
+
+
+
+{{/if}} diff --git a/ui/app/templates/components/identity/item-metadata.hbs b/ui/app/templates/components/identity/item-metadata.hbs new file mode 100644 index 000000000..efa6658b8 --- /dev/null +++ b/ui/app/templates/components/identity/item-metadata.hbs @@ -0,0 +1,26 @@ +{{#each-in model.metadata as |key value|}} +
+
+
+ {{key}} +
+
+ {{value}} +
+
+
+
+
+{{else}} +
+
+
+
+

+ There is no metadata associated with {{model.name}}. +

+
+
+
+
+{{/each-in}} diff --git a/ui/app/templates/components/identity/item-policies.hbs b/ui/app/templates/components/identity/item-policies.hbs new file mode 100644 index 000000000..2211c16f7 --- /dev/null +++ b/ui/app/templates/components/identity/item-policies.hbs @@ -0,0 +1,32 @@ +{{#each model.policies as |item|}} + {{#linked-block + "vault.cluster.policy.show" + "acl" + item + class="box is-sideless is-marginless" + }} +
+ +
+
+
+ {{/linked-block}} +{{else}} +
+
+
+
+

+ There are no policies associated with {{model.name}}. +

+
+
+
+
+{{/each}} + diff --git a/ui/app/templates/components/identity/lookup-input.hbs b/ui/app/templates/components/identity/lookup-input.hbs new file mode 100644 index 000000000..527df5676 --- /dev/null +++ b/ui/app/templates/components/identity/lookup-input.hbs @@ -0,0 +1,37 @@ +
+
+
+
+ +
+
+
+ {{#if (eq param "alias name")}} + {{mount-accessor-select + value=aliasMountAccessor + onChange=(action (mut aliasMountAccessor)) + }} + {{/if}} +
+
+ {{input + class="input" + value=paramValue + placeholder=(capitalize param) + }} +
+
+ +
+
+
diff --git a/ui/app/templates/components/info-table-row.hbs b/ui/app/templates/components/info-table-row.hbs new file mode 100644 index 000000000..7021a3397 --- /dev/null +++ b/ui/app/templates/components/info-table-row.hbs @@ -0,0 +1,18 @@ +{{#if (or alwaysRender value)}} +
+ {{label}} +
+
+ {{#if hasBlock}} + {{yield}} + {{else if valueIsBoolean}} + {{#if value}} + {{i-con glyph="true" size=15}} Yes + {{else}} + {{i-con glyph="false" size=15}} No + {{/if}} + {{else}} + {{value}} + {{/if}} +
+{{/if}} diff --git a/ui/app/templates/components/info-tooltip.hbs b/ui/app/templates/components/info-tooltip.hbs new file mode 100644 index 000000000..0a2085f1a --- /dev/null +++ b/ui/app/templates/components/info-tooltip.hbs @@ -0,0 +1,16 @@ +{{#tool-tip renderInPlace=true as |d|}} + {{#d.trigger tagName="button" class=(concat "tool-tip-trigger button") data-test-tool-tip-trigger=true}} + {{i-con + glyph="information-reversed" + class="auto-width" + size=16 + aria-label="help" + excludeIconClass=true + }} + {{/d.trigger}} + {{#d.content class="tool-tip"}} +
+ {{yield}} +
+ {{/d.content}} +{{/tool-tip}} diff --git a/ui/app/templates/components/key-value-header.hbs b/ui/app/templates/components/key-value-header.hbs new file mode 100644 index 000000000..272f4296d --- /dev/null +++ b/ui/app/templates/components/key-value-header.hbs @@ -0,0 +1,14 @@ +
    + {{#each secretPath as |path index|}} +
  • + / + {{#if linkToPaths}} + + {{path.text}} + + {{else}} + {{path.text}} + {{/if}} +
  • + {{/each}} +
diff --git a/ui/app/templates/components/key-version-select.hbs b/ui/app/templates/components/key-version-select.hbs new file mode 100644 index 000000000..7983a0fd0 --- /dev/null +++ b/ui/app/templates/components/key-version-select.hbs @@ -0,0 +1,26 @@ +{{#if (gt key.keysForEncryption.length 1)}} +
+ +
+
+ +
+
+
+{{/if}} diff --git a/ui/app/templates/components/kv-object-editor.hbs b/ui/app/templates/components/kv-object-editor.hbs new file mode 100644 index 000000000..275bf9754 --- /dev/null +++ b/ui/app/templates/components/kv-object-editor.hbs @@ -0,0 +1,57 @@ +{{#if label}} + +{{/if}} +{{#each kvData as |row index|}} +
+
+ {{input + data-test-kv-key=true + value=row.name + placeholder="key" + change=(action "updateRow" row index) + class="input" + }} +
+
+ {{textarea + data-test-kv-value=true + name=row.name + change=(action "updateRow" row index) + value=row.value + wrap="off" + class="input" + placeholder="value" + rows=1 + }} +
+
+ {{#if (eq kvData.length (inc index))}} + + {{else}} + + {{/if}} +
+
+{{/each}} +{{#if kvHasDuplicateKeys}} + {{#message-in-page type="warning" class="is-marginless" data-test-duplicate-error-warnings=true}} + More than one key shares the same name. Please be sure to have unique key names or some data may be lost when saving. + {{/message-in-page}} +{{/if}} diff --git a/ui/app/templates/components/list-pagination.hbs b/ui/app/templates/components/list-pagination.hbs new file mode 100644 index 000000000..b156e7675 --- /dev/null +++ b/ui/app/templates/components/list-pagination.hbs @@ -0,0 +1,107 @@ +{{#with (compact (flatten (array link model))) as |params|}} + +{{/with}} diff --git a/ui/app/templates/components/logo-splash.hbs b/ui/app/templates/components/logo-splash.hbs new file mode 100644 index 000000000..01ff28935 --- /dev/null +++ b/ui/app/templates/components/logo-splash.hbs @@ -0,0 +1,7 @@ +
+
+
+ {{partial "svg/vault-edition-logo"}} +
+
+
diff --git a/ui/app/templates/components/menu-sidebar.hbs b/ui/app/templates/components/menu-sidebar.hbs new file mode 100644 index 000000000..67203a5b0 --- /dev/null +++ b/ui/app/templates/components/menu-sidebar.hbs @@ -0,0 +1,21 @@ + diff --git a/ui/app/templates/components/message-error.hbs b/ui/app/templates/components/message-error.hbs new file mode 100644 index 000000000..e3244b258 --- /dev/null +++ b/ui/app/templates/components/message-error.hbs @@ -0,0 +1,7 @@ +{{#if displayErrors.length}} + {{#each displayErrors as |error|}} + {{#message-in-page type="danger" data-test-error=true}} + {{error}} + {{/message-in-page}} + {{/each}} +{{/if}} diff --git a/ui/app/templates/components/message-in-page.hbs b/ui/app/templates/components/message-in-page.hbs new file mode 100644 index 000000000..be3d60501 --- /dev/null +++ b/ui/app/templates/components/message-in-page.hbs @@ -0,0 +1,18 @@ +
+
+
+ {{i-con + glyph=alertType.glyph + class=alertType.glyphClass + size=22 + excludeIconClass=true + }} +
+
+

+ {{alertType.text}} + {{yield}} +

+
+
+
diff --git a/ui/app/templates/components/mount-accessor-select.hbs b/ui/app/templates/components/mount-accessor-select.hbs new file mode 100644 index 000000000..01eec0ca5 --- /dev/null +++ b/ui/app/templates/components/mount-accessor-select.hbs @@ -0,0 +1,41 @@ +{{#if label}} + +{{/if}} +{{#if authMethods.isRunning}} +
+ +
+{{else if authMethods.last.value}} +
+
+ +
+
+{{else}} + +{{/if}} + diff --git a/ui/app/templates/components/mount-backend-form.hbs b/ui/app/templates/components/mount-backend-form.hbs new file mode 100644 index 000000000..81eb4fd4a --- /dev/null +++ b/ui/app/templates/components/mount-backend-form.hbs @@ -0,0 +1,30 @@ + +
+
+ {{message-error model=mountModel}} + {{form-field-groups model=mountModel onChange=(action "onTypeChange") renderGroup="default"}} + {{#if mountModel.authConfigs.firstObject}} + {{form-field-groups model=mountModel.authConfigs.firstObject}} + {{/if}} + {{form-field-groups model=mountModel onChange=(action "onTypeChange") renderGroup="Method Options"}} +
+
+ +
+
diff --git a/ui/app/templates/components/mount-filter-config-list.hbs b/ui/app/templates/components/mount-filter-config-list.hbs new file mode 100644 index 000000000..ac64e5bf2 --- /dev/null +++ b/ui/app/templates/components/mount-filter-config-list.hbs @@ -0,0 +1,79 @@ + +
+
+ +
+

+ {{#if (eq config.mode "blacklist")}} + Selected mounts will be excluded when replicating to the secondary {{or id config.id}}. + {{else}} + Only the selected mounts will be replicated to the secondary {{or id config.id}}. + {{/if}} +

+
+ +
+
+
+
+
+
+ Mount Path +
+
+
+
+ Mount Type +
+
+
+{{#each mounts as |mount|}} + {{#unless (or mount.local (includes singletonMountTypes mount.type))}} + {{!-- auth mounts have apiPath, secret mounts use path --}} + {{#with (or mount.apiPath mount.path) as |path| }} + + {{/with}} + {{/unless}} +{{/each}} diff --git a/ui/app/templates/components/navigate-input.hbs b/ui/app/templates/components/navigate-input.hbs new file mode 100644 index 000000000..e1d1874be --- /dev/null +++ b/ui/app/templates/components/navigate-input.hbs @@ -0,0 +1,17 @@ +
+

+ {{input + value=filter + placeholder=(or placeholder "Filter keys") + class="filter input" + disabled=disabled + key-up=(action "handleKeyUp") + key-down=(action "handleKeyPress") + input=(action "handleInput") + focus-in=(action "setFilterFocused" true) + focus-out=(action "setFilterFocused" false) + }} + + {{i-con glyph="ios-search-strong" class="is-left has-text-grey" size=18}} +

+
diff --git a/ui/app/templates/components/not-found.hbs b/ui/app/templates/components/not-found.hbs new file mode 100644 index 000000000..7c4005513 --- /dev/null +++ b/ui/app/templates/components/not-found.hbs @@ -0,0 +1,15 @@ +
+ +
+

Sorry, we were unable to find any content at {{or model.path path}}.

+

Double check the url or go back {{home-link text="home"}}.

+
+
diff --git a/ui/app/templates/components/pgp-file.hbs b/ui/app/templates/components/pgp-file.hbs new file mode 100644 index 000000000..8526d9e37 --- /dev/null +++ b/ui/app/templates/components/pgp-file.hbs @@ -0,0 +1,77 @@ +
+
+ +
+
+
+ + +
+
+
+
+ {{#if key.enterAsText}} +
+ +
+

+ {{#if textareaHelpText}} + {{textareaHelpText}} + {{else}} + Enter a base64-encoded key + {{/if}} +

+ {{else}} +
+
+ +
+
+

+ {{#if fileHelpText}} + {{fileHelpText}} + {{else}} + Select a PGP key from your computer + {{/if}} +

+ {{/if}} +
diff --git a/ui/app/templates/components/pgp-list.hbs b/ui/app/templates/components/pgp-list.hbs new file mode 100644 index 000000000..f4052c0dc --- /dev/null +++ b/ui/app/templates/components/pgp-list.hbs @@ -0,0 +1,7 @@ +{{#each listData as |key index|}} + {{pgp-file key=key index=index onChange=(action 'setKey')}} +{{else}} +

+ Enter a number of Key Shares to enter PGP keys. +

+{{/each}} diff --git a/ui/app/templates/components/pki-cert-popup.hbs b/ui/app/templates/components/pki-cert-popup.hbs new file mode 100644 index 000000000..90eb720f1 --- /dev/null +++ b/ui/app/templates/components/pki-cert-popup.hbs @@ -0,0 +1,29 @@ +{{#popup-menu name="role-aws-nav" contentClass="is-wide"}} + +{{/popup-menu}} diff --git a/ui/app/templates/components/pki-cert-show.hbs b/ui/app/templates/components/pki-cert-show.hbs new file mode 100644 index 000000000..56d817e67 --- /dev/null +++ b/ui/app/templates/components/pki-cert-show.hbs @@ -0,0 +1,61 @@ + + +
+ {{message-error model=model}} + {{#each model.attrs as |attr|}} + {{#if (eq attr.type "object")}} + {{info-table-row label=(capitalize (or attr.options.label (humanize (dasherize attr.name)))) value=(stringify (get model attr.name))}} + {{else}} + {{info-table-row label=(capitalize (or attr.options.label (humanize (dasherize attr.name)))) value=(get model attr.name)}} + {{/if}} + {{/each}} +
+
+
+
+ {{#copy-button + clipboardText=model.toCreds + class="button is-primary" + buttonType="button" + success=(action (set-flash-message "Credentials copied!")) + }} + Copy credentials + {{/copy-button}} +
+
+ {{#link-to + "vault.cluster.secrets.backend.list-root" + (query-params tab="certs") + class="button" + }} + Back + {{/link-to}} +
+
+ {{#if (and (not model.revocationTime) model.canRevoke)}} + {{#confirm-action + buttonClasses="button" + onConfirmAction=(action "delete") + confirmMessage=(concat "Are you sure you want to revoke " model.id "?") + cancelButtonText="Cancel" + confirmButtonText="Revoke" + }} + Revoke + {{/confirm-action}} + {{/if}} +
diff --git a/ui/app/templates/components/popup-menu.hbs b/ui/app/templates/components/popup-menu.hbs new file mode 100644 index 000000000..12f07223e --- /dev/null +++ b/ui/app/templates/components/popup-menu.hbs @@ -0,0 +1,15 @@ +{{#basic-dropdown class="popup-menu" horizontalPosition="auto-right" verticalPosition="below" onOpen=onOpen as |d|}} + {{#d.trigger tagName="button" class=(concat "popup-menu-trigger button is-transparent " (if d.isOpen "is-active")) data-test-popup-menu-trigger=true}} + {{i-con + glyph="more" + class="has-text-black auto-width" + size=16 + aria-label="More options" + }} + {{/d.trigger}} + {{#d.content class=(concat "popup-menu-content " contentClass)}} +
+ {{yield}} +
+ {{/d.content}} +{{/basic-dropdown}} diff --git a/ui/app/templates/components/replication-actions.hbs b/ui/app/templates/components/replication-actions.hbs new file mode 100644 index 000000000..08c0c031c --- /dev/null +++ b/ui/app/templates/components/replication-actions.hbs @@ -0,0 +1,5 @@ +{{message-error errors=errors}} +{{#if loading}} +{{else}} + {{partial (concat 'partials/replication/' selectedAction)}} +{{/if}} diff --git a/ui/app/templates/components/replication-mode-summary.hbs b/ui/app/templates/components/replication-mode-summary.hbs new file mode 100644 index 000000000..d3fb34c19 --- /dev/null +++ b/ui/app/templates/components/replication-mode-summary.hbs @@ -0,0 +1 @@ +{{partial partialName}} diff --git a/ui/app/templates/components/replication-summary.hbs b/ui/app/templates/components/replication-summary.hbs new file mode 100644 index 000000000..3d0b2bad3 --- /dev/null +++ b/ui/app/templates/components/replication-summary.hbs @@ -0,0 +1,24 @@ +{{#if (not version.hasDRReplication)}} + {{upgrade-page title="Replication"}} +{{else if (or cluster.allReplicationDisabled cluster.replicationAttrs.replicationDisabled)}} + {{partial 'partials/replication/enable'}} +{{else if showModeSummary}} + {{partial 'partials/replication/mode-summary'}} +{{else}} + {{#if (eq replicationAttrs.mode 'initializing')}} + The cluster is initializing replication. This may take some time. + {{else}} + {{info-table-row label="Mode" value=replicationAttrs.mode}} + {{info-table-row label="Replication set" value=replicationAttrs.clusterId}} + {{info-table-row label="Secondary ID" value=replicationAttrs.secondaryId}} + {{info-table-row label="State" value=replicationAttrs.state}} + {{info-table-row label="Primary cluster address" value=replicationAttrs.primaryClusterAddr}} + {{info-table-row label="Replication state" value=replicationAttrs.replicationState}} + {{info-table-row label="Last WAL entry" value=replicationAttrs.lastWAL}} + {{info-table-row label="Last Remote WAL entry" value=replicationAttrs.lastRemoteWAL}} + {{info-table-row label="Merkle root index" value=replicationAttrs.merkleRoot}} + {{#if replicationAttrs.syncProgress}} + {{info-table-row label="Sync progress" value=(concat replicationAttrs.syncProgress.progress '/' replicationAttrs.syncProgress.total)}} + {{/if}} + {{/if}} +{{/if}} diff --git a/ui/app/templates/components/role-aws-edit.hbs b/ui/app/templates/components/role-aws-edit.hbs new file mode 100644 index 000000000..cae7480e2 --- /dev/null +++ b/ui/app/templates/components/role-aws-edit.hbs @@ -0,0 +1,74 @@ + + +{{#if (or (eq mode 'edit') (eq mode 'create'))}} + {{partial 'partials/role-aws/form'}} +{{else}} + {{partial 'partials/role-aws/show'}} +{{/if}} diff --git a/ui/app/templates/components/role-pki-edit.hbs b/ui/app/templates/components/role-pki-edit.hbs new file mode 100644 index 000000000..f73ec1884 --- /dev/null +++ b/ui/app/templates/components/role-pki-edit.hbs @@ -0,0 +1,76 @@ + + +{{#if (or (eq mode 'edit') (eq mode 'create'))}} + {{partial 'partials/role-pki/form'}} +{{else}} + {{partial 'partials/role-pki/show'}} +{{/if}} diff --git a/ui/app/templates/components/role-ssh-edit.hbs b/ui/app/templates/components/role-ssh-edit.hbs new file mode 100644 index 000000000..efdc98210 --- /dev/null +++ b/ui/app/templates/components/role-ssh-edit.hbs @@ -0,0 +1,70 @@ + + +{{#if (or (eq mode 'edit') (eq mode 'create'))}} + {{partial 'partials/role-ssh/form'}} +{{else}} + {{partial 'partials/role-ssh/show'}} +{{/if}} diff --git a/ui/app/templates/components/secret-edit.hbs b/ui/app/templates/components/secret-edit.hbs new file mode 100644 index 000000000..679937d99 --- /dev/null +++ b/ui/app/templates/components/secret-edit.hbs @@ -0,0 +1,48 @@ + +
+
+
+
+
+
+
+ + +
+ {{#if (and (not-eq mode 'create') (or capabilities.canUpdate capabilities.canDelete))}} +
+ {{input + id="edit" + type="checkbox" + name="navToEdit" + class="switch is-rounded is-success is-small" + checked=(eq mode 'edit') + change=(action (nav-to-route (concat 'vault.cluster.secrets.backend.' (if (eq mode 'show') 'edit' 'show')) key.id replace=true) ) + }} + +
+ {{/if}} +
+
+
+
+{{partial partialName}} diff --git a/ui/app/templates/components/section-tabs.hbs b/ui/app/templates/components/section-tabs.hbs new file mode 100644 index 000000000..842eca60f --- /dev/null +++ b/ui/app/templates/components/section-tabs.hbs @@ -0,0 +1,17 @@ +{{#with (tabs-for-auth-section model.type tabType) as |tabs|}} + {{#if tabs.length}} +
+ +
+ {{/if}} +{{/with}} diff --git a/ui/app/templates/components/shamir-flow.hbs b/ui/app/templates/components/shamir-flow.hbs new file mode 100644 index 000000000..5c5d22403 --- /dev/null +++ b/ui/app/templates/components/shamir-flow.hbs @@ -0,0 +1,160 @@ +{{#if encoded_token}} +
+
+
+

+ Encoded Operation Token +

+ {{encoded_token}} +
+
+

+ If you entered a One Time Password, you can use the Vault CLI to decode the Token: +

+
+
+

+ DR Operation Token Command +

+ + vault generate-root -otp="[enter your otp here]" -decode="{{encoded_token}}" + +
+
+ {{#copy-button + clipboardText=(concat 'vault generate-root -otp="" -decode="' encoded_token '"') + class="button is-compact" + buttonType="button" + success=(action (set-flash-message 'CLI command copied!')) + }} + Copy CLI command + {{/copy-button}} +
+
+ {{#copy-button + clipboardText=encoded_token + class="button is-primary" + buttonType="button" + success=(action (set-flash-message 'OTP copied!')) + }} + Copy Encoded Operation Token + {{/copy-button}} + +
+{{else if (and generateAction (not started))}} +
+
+ {{message-error errors=errors}} +

+ Updating or promoting this cluster requires an operation token. Let's generate one by + inputting the master key shares. To get started you'll need to generate a One Time Password + (OTP) or a PGP Key to encrypt the resultant operation token. +

+
+
+
+
+ {{input type="checkbox" + id="generateWithPGP" + class="styled" + checked=generateWithPGP + }} + +
+
+ {{#unless generateWithPGP}} +
+
+ +
+
+ {{/unless}} +
+
+ {{#if generateWithPGP}} + {{pgp-file index='' key=pgpKeyFile onChange=(action 'setKey')}} + {{else}} + {{#if otp}} +
+
+

+ One Time Password +

+ {{otp}} +
+
+ {{/if}} +
+ {{#if otp}} +
+ {{#copy-button + clipboardText=otp + class="button is-compact" + buttonType="button" + success=(action (set-flash-message 'OTP copied!')) + }} + Copy OTP + {{/copy-button}} +
+

Make sure to save this value for later.

+ {{/if}} +
+ {{/if}} +
+
+ +
+
+{{else}} +
+ {{message-error errors=errors}} +
+
+ {{#if hasBlock}} + {{yield}} + {{else}} +

{{formText}}

+ {{/if}} +
+
+ +
+ {{input class="input"type="password" name="key" value=key data-test-shamir-input=true}} +
+
+
+
+
+
+
+ +
+
+ {{#if (or started hasProgress)}} + {{shamir-progress + threshold=threshold + progress=progress + }} + {{/if}} +
+
+
+{{/if}} diff --git a/ui/app/templates/components/shamir-progress.hbs b/ui/app/templates/components/shamir-progress.hbs new file mode 100644 index 000000000..63f9f14be --- /dev/null +++ b/ui/app/templates/components/shamir-progress.hbs @@ -0,0 +1,8 @@ +
+
+ + {{progress}} / {{threshold}} keys provided + + +
+
diff --git a/ui/app/templates/components/splash-page.hbs b/ui/app/templates/components/splash-page.hbs new file mode 100644 index 000000000..4f04020cb --- /dev/null +++ b/ui/app/templates/components/splash-page.hbs @@ -0,0 +1,18 @@ +
+
+
+
+
+
+
+ {{partial "svg/vault-edition-logo"}} +
+ {{yield (hash header=(component 'splash-page/splash-header'))}} +
+
+ {{yield (hash content=(component 'splash-page/splash-content'))}} +
+ {{yield (hash footer=(component 'splash-page/splash-content')) }} +
+
+
diff --git a/ui/app/templates/components/splash-page/splash-content.hbs b/ui/app/templates/components/splash-page/splash-content.hbs new file mode 100644 index 000000000..889d9eead --- /dev/null +++ b/ui/app/templates/components/splash-page/splash-content.hbs @@ -0,0 +1 @@ +{{yield}} diff --git a/ui/app/templates/components/splash-page/splash-footer.hbs b/ui/app/templates/components/splash-page/splash-footer.hbs new file mode 100644 index 000000000..889d9eead --- /dev/null +++ b/ui/app/templates/components/splash-page/splash-footer.hbs @@ -0,0 +1 @@ +{{yield}} diff --git a/ui/app/templates/components/splash-page/splash-header.hbs b/ui/app/templates/components/splash-page/splash-header.hbs new file mode 100644 index 000000000..889d9eead --- /dev/null +++ b/ui/app/templates/components/splash-page/splash-header.hbs @@ -0,0 +1 @@ +{{yield}} diff --git a/ui/app/templates/components/status-menu.hbs b/ui/app/templates/components/status-menu.hbs new file mode 100644 index 000000000..f3ac08fcc --- /dev/null +++ b/ui/app/templates/components/status-menu.hbs @@ -0,0 +1,31 @@ +{{#basic-dropdown-hover horizontalPosition="auto-right" verticalPosition="below" as |d|}} + {{#d.trigger tagName=(if (eq type "replication") "span" "button") class=(if (eq type "replication") "" "button is-transparent")}} + {{#if (eq type "user")}} +
+ {{i-con + glyph=glyphName + class="has-text-dark-grey auto-width" + size=16 + aria-label=ariaLabel + }} +
+ {{i-con glyph="chevron-down" aria-hidden="true" size=8 class="has-text-white auto-width is-status-chevron"}} + {{else if (eq type "replication")}} + + Replication + {{i-con glyph="chevron-down" aria-hidden="true" excludeIconClass=true size=8 class="auto-width is-status-chevron is-marginless"}} + + {{else}} + {{i-con + glyph=glyphName + class="has-text-white auto-width" + size=16 + aria-label=ariaLabel + }} + {{i-con glyph="chevron-down" aria-hidden="true" size=8 class="has-text-white auto-width is-status-chevron"}} + {{/if}} + {{/d.trigger}} + {{#d.content class=(concat "status-menu-content status-menu-content-" type)}} + {{partial partialName}} + {{/d.content}} +{{/basic-dropdown-hover}} diff --git a/ui/app/templates/components/string-list.hbs b/ui/app/templates/components/string-list.hbs new file mode 100644 index 000000000..d0a15625c --- /dev/null +++ b/ui/app/templates/components/string-list.hbs @@ -0,0 +1,42 @@ +{{#if label}} + +{{/if}} +{{#if warning}} + {{#message-in-page type="warning"}} + {{warning}} + {{/message-in-page}} +{{/if}} +{{#each inputList as |data index|}} +
+
+ {{input + data-test-string-list-input=index + type="text" + class="input" + value=data.value + name=(concat elementId "-" index) + id=(concat elementId "-" index) + key-up=(action "inputChanged" index) + change=(action (action "inputChanged" index) value="target.value") + }} +
+
+ {{#if (eq (inc index) inputList.length)}} + + {{else}} + + {{/if}} +
+
+{{/each}} diff --git a/ui/app/templates/components/text-file.hbs b/ui/app/templates/components/text-file.hbs new file mode 100644 index 000000000..c2fadf850 --- /dev/null +++ b/ui/app/templates/components/text-file.hbs @@ -0,0 +1,71 @@ +{{#unless inputOnly}} +
+
+ +
+
+
+ + +
+
+
+{{/unless}} +
+ {{#if file.enterAsText}} +
+ +
+

+ {{textareaHelpText}} +

+ {{else}} +
+
+ +
+
+

+ {{fileHelpText}} +

+ {{/if}} +
diff --git a/ui/app/templates/components/toggle-button.hbs b/ui/app/templates/components/toggle-button.hbs new file mode 100644 index 000000000..bd1fb85ab --- /dev/null +++ b/ui/app/templates/components/toggle-button.hbs @@ -0,0 +1,5 @@ +{{#if isOpen}} + {{i-con glyph='chevron-up' exludeIconClass=true}}  {{openLabel}} +{{else}} + {{i-con glyph='chevron-down' exludeIconClass=true}}  {{closedLabel}} +{{/if}} diff --git a/ui/app/templates/components/token-expire-warning.hbs b/ui/app/templates/components/token-expire-warning.hbs new file mode 100644 index 000000000..4dfe0785a --- /dev/null +++ b/ui/app/templates/components/token-expire-warning.hbs @@ -0,0 +1,28 @@ +{{#unless (and isDismissed (is-before (now interval=1000) auth.tokenExpirationDate))}} + {{#if (is-after (now interval=1000) auth.tokenExpirationDate)}} + {{#message-in-page type="danger"}} +
+

+ Your auth token expired at {{moment-format auth.tokenExpirationDate 'YYYY-MM-DD HH:mm:ss'}}. +

+
+ + {{/message-in-page}} + {{else if auth.allowExpiration}} + {{#message-in-page type="warning"}} +
+

+ We've determined you are inactive, and have stopped auto-renewing your current auth token. + Your token will expire in {{moment-from-now auth.tokenExpirationDate interval=1000 hideSuffix=true}} at + {{moment-format auth.tokenExpirationDate 'YYYY-MM-DD HH:mm:ss'}} +

+ +
+ + {{/message-in-page}} + {{/if}} +{{/unless}} diff --git a/ui/app/templates/components/tool-actions-form.hbs b/ui/app/templates/components/tool-actions-form.hbs new file mode 100644 index 000000000..93a5e9654 --- /dev/null +++ b/ui/app/templates/components/tool-actions-form.hbs @@ -0,0 +1,4 @@ +{{message-error errors=errors}} +
+ {{partial (concat "partials/tools/" selectedAction)}} +
diff --git a/ui/app/templates/components/tool-tip.hbs b/ui/app/templates/components/tool-tip.hbs new file mode 100644 index 000000000..4526b6424 --- /dev/null +++ b/ui/app/templates/components/tool-tip.hbs @@ -0,0 +1,27 @@ +{{#basic-dropdown-hover + renderInPlace=renderInPlace + verticalPosition=verticalPosition + horizontalPosition=horizontalPosition + matchTriggerWidth=matchTriggerWidth + triggerComponent=triggerComponent + contentComponent=contentComponent + calculatePosition=calculatePosition + calculateInPlacePosition=calculateInPlacePosition as |dd|}} + {{yield (assign + dd + (hash + trigger=(component dd.trigger + onMouseDown=(action "prevent") + onMouseEnter=(action "open") + onMouseLeave=(action "close") + onBlur=(action "close") + ) + content=(component dd.content + onMouseEnter=(action "open") + onMouseLeave=(action "close") + onFocus=(action "open") + onBlur=(action "close") + ) + ) + )}} +{{/basic-dropdown-hover}} diff --git a/ui/app/templates/components/transit-edit.hbs b/ui/app/templates/components/transit-edit.hbs new file mode 100644 index 000000000..3c5f68433 --- /dev/null +++ b/ui/app/templates/components/transit-edit.hbs @@ -0,0 +1,54 @@ + + +{{partial (concat 'partials/transit-form-' mode)}} diff --git a/ui/app/templates/components/transit-key-action/datakey.hbs b/ui/app/templates/components/transit-key-action/datakey.hbs new file mode 100644 index 000000000..a408f1467 --- /dev/null +++ b/ui/app/templates/components/transit-key-action/datakey.hbs @@ -0,0 +1,123 @@ +
+ {{#if ciphertext}} +
+ {{#if (eq param 'plaintext')}} +
+ +
+ +
+
+ {{/if}} +
+ +
+ +
+
+
+
+ {{#if (eq param 'plaintext')}} +
+ {{#copy-button + clipboardTarget="#plaintext" + class="button is-primary" + buttonType="button" + success=(action (set-flash-message 'Plaintext copied!')) + }} + Copy plaintext + {{/copy-button}} +
+ {{/if}} +
+ {{#copy-button + clipboardTarget="#ciphertext" + class=(concat "button is-primary " (if (eq param "plaintext") "is-outlined" "")) + buttonType="button" + success=(action (set-flash-message 'Ciphertext copied!')) + }} + Copy ciphertext + {{/copy-button}} +
+
+ +
+
+ {{else}} +
+
+ +
+
+ +
+
+
+ {{#if key.derived}} +
+ +
+
+ {{input type="text" id="context" value=context class="input" data-test-transit-input="context"}} +
+
+ {{b64-toggle value=context data-test-transit-b64-toggle="context"}} +
+
+
+ {{/if}} + {{#if (eq key.convergentEncryptionVersion 1)}} +
+ +
+
+ {{input type="text" id="nonce" value=nonce class="input" data-test-transit-input="nonce"}} +
+
+ {{b64-toggle value=nonce data-test-transit-b64-toggle="nonce"}} +
+
+
+ {{/if}} +
+ +
+
+ +
+
+
+
+
+
+ +
+
+ {{/if}} +
diff --git a/ui/app/templates/components/transit-key-action/decrypt.hbs b/ui/app/templates/components/transit-key-action/decrypt.hbs new file mode 100644 index 000000000..93f5654bb --- /dev/null +++ b/ui/app/templates/components/transit-key-action/decrypt.hbs @@ -0,0 +1,76 @@ +
+ {{#if (and plaintext ciphertext)}} +
+
+ +
+ {{textarea id="plaintext" value=plaintext readonly=true class="textarea" data-test-transit-input="plaintext"}} + {{b64-toggle value=plaintext isInput=false initialEncoding="base64" data-test-transit-b64-toggle="plaintext"}} +
+
+
+
+
+ {{#copy-button + clipboardTarget="#plaintext" + class="button is-primary" + buttonType="button" + success=(action (set-flash-message 'Plaintext copied!')) + }} + Copy + {{/copy-button}} +
+
+ +
+
+ {{else}} +
+
+ +
+ {{textarea id="ciphertext" name="ciphertext" value=ciphertext class="textarea" data-test-transit-input="ciphertext"}} +
+
+ {{#if key.derived}} +
+ +
+
+ {{input type="text" id="context" value=context class="input" data-test-transit-input="context"}} +
+
+ {{b64-toggle value=context data-test-transit-b64-toggle="context"}} +
+
+
+ {{/if}} + {{#if (eq key.convergentEncryptionVersion 1)}} +
+ +
+
+ {{input type="text" id="nonce" value=nonce class="input" data-test-transit-input="nonce"}} +
+
+ {{b64-toggle value=nonce data-test-transit-b64-toggle="nonce"}} +
+
+
+ {{/if}} +
+
+
+ +
+
+ {{/if}} +
diff --git a/ui/app/templates/components/transit-key-action/encrypt.hbs b/ui/app/templates/components/transit-key-action/encrypt.hbs new file mode 100644 index 000000000..8316972e9 --- /dev/null +++ b/ui/app/templates/components/transit-key-action/encrypt.hbs @@ -0,0 +1,83 @@ +
+ {{#if (and plaintext ciphertext)}} +
+
+ +
+ +
+
+
+
+
+ {{#copy-button + clipboardTarget="#ciphertext" + class="button is-primary" + buttonType="button" + success=(action (set-flash-message 'Ciphertext copied!')) + }} + Copy + {{/copy-button}} +
+
+ +
+
+ {{else}} +
+ {{key-version-select + key=key + onVersionChange=(action (mut key_version)) + key_version=key_version + }} +
+ +
+ {{textarea id="plaintext" value=plaintext class="textarea" data-test-transit-input="plaintext"}} + {{b64-toggle value=plaintext isInput=false data-test-transit-b64-toggle="plaintext"}} +
+
+ {{#if key.derived}} +
+ +
+
+ {{input type="text" id="context" value=context class="input" data-test-transit-input="context"}} +
+
+ {{b64-toggle value=context data-test-transit-b64-toggle="context"}} +
+
+
+ {{/if}} + {{#if (eq key.convergentEncryptionVersion 1)}} +
+
+
+ +
+
+ {{b64-toggle value=nonce data-test-transit-b64-toggle="nonce"}} +
+
+
+ {{input type="text" id="nonce" value=nonce class="input" data-test-transit-input="nonce"}} +
+
+ {{/if}} +
+
+
+ +
+
+ {{/if}} +
diff --git a/ui/app/templates/components/transit-key-action/export.hbs b/ui/app/templates/components/transit-key-action/export.hbs new file mode 100644 index 000000000..579ae1f95 --- /dev/null +++ b/ui/app/templates/components/transit-key-action/export.hbs @@ -0,0 +1,103 @@ +
+ {{#if (or keys wrappedToken) }} +
+
+ {{#if wrapTTL}} + +
+ +
+ {{else}} + + {{json-editor + value=(stringify keys) + options=(hash + readOnly=true + ) + }} + {{/if}} +
+
+
+
+ {{#copy-button + clipboardText=(if wrapTTL wrappedToken (stringify keys)) + class="button is-primary" + buttonType="button" + success=(action (set-flash-message (if wrapTTL 'Wrapped key copied!' 'Exported key copied!'))) + }} + Copy + {{/copy-button}} +
+
+ +
+
+ {{else}} +
+
+ +
+
+ +
+
+
+
+
+ {{input type="checkbox" name="exportVersion" id="exportVersion" class="styled" checked=exportVersion}} + +
+ {{#if exportVersion}} +
+ +
+
+ +
+
+
+ {{/if}} +
+ {{wrap-ttl onChange=(action (mut wrapTTL))}} +
+
+
+ +
+
+ {{/if}} +
diff --git a/ui/app/templates/components/transit-key-action/hmac.hbs b/ui/app/templates/components/transit-key-action/hmac.hbs new file mode 100644 index 000000000..06d4931d1 --- /dev/null +++ b/ui/app/templates/components/transit-key-action/hmac.hbs @@ -0,0 +1,71 @@ +
+ {{#if hmac}} +
+
+ +
+ +
+
+
+
+
+ {{#copy-button + clipboardTarget="#hmac" + class="button is-primary" + buttonType="button" + success=(action (set-flash-message 'HMAC copied!')) + }} + Copy + {{/copy-button}} +
+
+ +
+
+ {{else}} +
+ {{key-version-select + key=key + onVersionChange=(action (mut key_version)) + key_version=key_version + }} +
+ +
+ {{textarea id="input" name="input" value=input class="textarea" data-test-transit-input="input"}} + {{b64-toggle value=input isInput=false data-test-transit-b64-toggle="input"}} +
+
+
+ +
+
+ +
+
+
+
+
+
+ +
+
+ {{/if}} +
diff --git a/ui/app/templates/components/transit-key-action/rewrap.hbs b/ui/app/templates/components/transit-key-action/rewrap.hbs new file mode 100644 index 000000000..daff9f9ac --- /dev/null +++ b/ui/app/templates/components/transit-key-action/rewrap.hbs @@ -0,0 +1,55 @@ +
+
+ {{key-version-select + key=key + onVersionChange=(action (mut key_version)) + key_version=key_version + }} +
+ +
+ {{textarea name="ciphertext" class="textarea" id="ciphertext" value=ciphertext}} +
+
+ {{#if key.derived}} +
+ +
+
+ {{input type="text" id="context" value=context class="input" data-test-transit-input="context"}} +
+
+ {{b64-toggle value=context data-test-transit-b64-toggle="context"}} +
+
+
+ {{/if}} + {{#if (eq key.convergentEncryptionVersion 1)}} +
+ +
+
+ {{input type="text" id="nonce" value=nonce class="input" data-test-transit-input="nonce"}} +
+
+ {{b64-toggle value=nonce data-test-transit-b64-toggle="nonce"}} +
+
+
+ {{/if}} +
+
+
+

+ Submitting this form will update the ciphertext in-place. +

+
+
+ +
+
+
diff --git a/ui/app/templates/components/transit-key-action/sign.hbs b/ui/app/templates/components/transit-key-action/sign.hbs new file mode 100644 index 000000000..d2a1b4b72 --- /dev/null +++ b/ui/app/templates/components/transit-key-action/sign.hbs @@ -0,0 +1,96 @@ +
+ {{#if signature}} +
+
+ +
+ +
+
+
+
+
+ {{#copy-button + clipboardTarget="#signature" + class="button is-primary" + buttonType="button" + success=(action (set-flash-message 'Signature copied!')) + }} + Copy + {{/copy-button}} +
+
+ +
+
+ {{else}} +
+ {{key-version-select + key=key + onVersionChange=(action (mut key_version)) + key_version=key_version + }} +
+ +
+ {{textarea id="input" name="input" value=input class="textarea" data-test-transit-input="input"}} + {{b64-toggle value=input isInput=false data-test-transit-b64-toggle="input"}} +
+
+ {{#if key.derived}} +
+ +
+
+ {{input type="text" id="context" value=context class="input" data-test-transit-input="context"}} +
+
+ {{b64-toggle value=context data-test-transit-b64-toggle="context"}} +
+
+
+ {{/if}} +
+
+
+ +
+
+
+ {{input id="prehashed" type="checkbox" name="prehashed" class="switch is-rounded is-success is-small" checked=prehashed }} + +
+
+
+
+
+ +
+
+
+
+
+
+ +
+
+ {{/if}} +
diff --git a/ui/app/templates/components/transit-key-action/verify.hbs b/ui/app/templates/components/transit-key-action/verify.hbs new file mode 100644 index 000000000..20e9dc186 --- /dev/null +++ b/ui/app/templates/components/transit-key-action/verify.hbs @@ -0,0 +1,155 @@ +
+ {{#if (not-eq valid null)}} +
+

Verified

+
+
+
+

+ {{i-con glyph=(if valid 'checkmark' 'close') }} + The input is {{if valid 'valid' 'not valid'}} for the given {{if signature 'signature' 'hmac'}}. +

+
+
+
+
+
+ +
+ {{else}} +
+
+ +
+ {{textarea id="input" name="input" value=input class="textarea" data-test-transit-input="input"}} + {{b64-toggle value=input isInput=false data-test-transit-b64-toggle="input"}} +
+
+ {{#if (and key.supportsSigning key.derived (not hmac))}} +
+ +
+
+ {{input type="text" id="context" value=context class="input" data-test-transit-input="context"}} +
+
+ {{b64-toggle value=context data-test-transit-b64-toggle="context"}} +
+
+
+ {{/if}} + {{#if key.supportsSigning}} +
+
+
+ +
+
+ +
+
+
+
+
+
+ +
+
+ {{#unless (eq verification 'HMAC')}} +
+ {{input id="prehashed" type="checkbox" name="prehashed" class="switch is-rounded is-success is-small" checked=prehashed }} + +
+ {{/unless}} +
+
+
+
+ +
+
+
+
+
+ {{#if (or (and verification (eq verification 'HMAC')) hmac)}} +
+ +
+ {{textarea class="textarea" id="hmac" value=hmac}} +
+
+ {{else}} +
+ +
+ {{textarea id="signature" class="textarea" value=signature}} +
+
+ {{/if}} +
+
+ {{else}} +
+ +
+ {{textarea class="textarea" id="hmac" value=hmac}} +
+
+
+ +
+
+ +
+
+
+ {{/if}} +
+
+
+ +
+
+ {{/if}} +
diff --git a/ui/app/templates/components/transit-key-actions.hbs b/ui/app/templates/components/transit-key-actions.hbs new file mode 100644 index 000000000..2ba1fbdc5 --- /dev/null +++ b/ui/app/templates/components/transit-key-actions.hbs @@ -0,0 +1,26 @@ +{{#if (eq selectedAction 'rotate')}} + {{#if key.canRotate}} +
+
+ {{#confirm-action + buttonClasses="button" + onConfirmAction=(action "doSubmit") + confirmMessage=(concat 'Are you sure you want to rotate "' key.id '"?') + confirmButtonText="Confirm rotation" + cancelButtonText="Cancel" + messageClasses="is-block-mobile has-text-grey" + data-test-transit-key-rotate=true + }} + Rotate encryption key + {{/confirm-action}} +
+
+ {{/if}} +{{else}} + {{message-error errors=errors}} + {{#if selectedAction}} +
+ {{partial (concat 'components/transit-key-action/' selectedAction)}} +
+ {{/if}} +{{/if}} diff --git a/ui/app/templates/components/ttl-picker.hbs b/ui/app/templates/components/ttl-picker.hbs new file mode 100644 index 000000000..ce6ec531f --- /dev/null +++ b/ui/app/templates/components/ttl-picker.hbs @@ -0,0 +1,30 @@ + +
+
+ +
+
+
+ +
+
+
diff --git a/ui/app/templates/components/upgrade-link.hbs b/ui/app/templates/components/upgrade-link.hbs new file mode 100644 index 000000000..611a67d37 --- /dev/null +++ b/ui/app/templates/components/upgrade-link.hbs @@ -0,0 +1,7 @@ + + +{{#ember-wormhole to="modal-wormhole"}} + {{partial "partials/upgrade-overlay"}} +{{/ember-wormhole}} diff --git a/ui/app/templates/components/upgrade-page.hbs b/ui/app/templates/components/upgrade-page.hbs new file mode 100644 index 000000000..c5c9039f7 --- /dev/null +++ b/ui/app/templates/components/upgrade-page.hbs @@ -0,0 +1,20 @@ + + +
+

+ + {{featureName}} is a {{minimumEdition}} feature. + + You can upgrade to {{minimumEdition}} to unlock additional collaboration and security features +

+

+ {{#upgrade-link linkClass="button is-ghost has-icon-right" data-test-upgrade-link="true"}} + Vault Enterprise + {{i-con glyph="chevron-right"}} + {{/upgrade-link}} +

+
diff --git a/ui/app/templates/loading.hbs b/ui/app/templates/loading.hbs new file mode 100644 index 000000000..a21f00dc4 --- /dev/null +++ b/ui/app/templates/loading.hbs @@ -0,0 +1 @@ +{{logo-splash}} diff --git a/ui/app/templates/partials/backend-details.hbs b/ui/app/templates/partials/backend-details.hbs new file mode 100644 index 000000000..65fe91827 --- /dev/null +++ b/ui/app/templates/partials/backend-details.hbs @@ -0,0 +1,34 @@ +
+ {{#if backend.description}} +
+
+

Description

+

+ {{capitalize backend.description}}. +

+
+
+ {{/if}} +
+
+

Default TTL

+ + {{backend.config.default_lease_ttl}} + +
+
+
+
+

Max TTL

+ + {{backend.config.max_lease_ttl}} + +
+
+
+
+

Replicated behavior

+ {{backend.localDisplay}} +
+
+
diff --git a/ui/app/templates/partials/form-field-from-model.hbs b/ui/app/templates/partials/form-field-from-model.hbs new file mode 100644 index 000000000..43acae746 --- /dev/null +++ b/ui/app/templates/partials/form-field-from-model.hbs @@ -0,0 +1,59 @@ +
+ {{#unless (or attr.options.editType (eq attr.type 'boolean'))}} + + {{/unless}} + {{#if attr.options.possibleValues}} +
+
+ +
+
+ {{else if (eq attr.options.editType 'ttl')}} + {{ttl-picker initialValue=(or (get model attr.name) attr.options.defaultValue) labelText=(if attr.options.label attr.options.label (humanize (dasherize attr.name))) setDefaultValue=false onChange=(action (mut (get model attr.name)))}} + {{else if (or (eq attr.type 'number') (eq attr.type 'string'))}} +
+ {{input id=attr.name value=(get model (or attr.options.fieldValue attr.name)) class="input" data-test-input=attr.name}} +
+ {{else if (eq attr.type 'boolean')}} +
+ + +
+ {{else if (eq attr.type 'object')}} + {{json-editor + value=(if (get model attr.name) (stringify (get model attr.name)) emptyData) + valueUpdated=(action "codemirrorUpdated" attr.name) + }} + {{/if}} +
diff --git a/ui/app/templates/partials/form-field-groups-loop.hbs b/ui/app/templates/partials/form-field-groups-loop.hbs new file mode 100644 index 000000000..2509a4073 --- /dev/null +++ b/ui/app/templates/partials/form-field-groups-loop.hbs @@ -0,0 +1,27 @@ +{{#each model.fieldGroups as |fieldGroup|}} + {{#each-in fieldGroup as |group fields|}} + {{#if (eq group "default")}} + {{#each fields as |attr|}} + {{#unless (and (not-eq mode 'create') (eq attr.name "name"))}} + {{form-field data-test-field attr=attr model=model}} + {{/unless}} + {{/each}} + {{else}} + {{toggle-button + class="is-block" + toggleAttr=(concat "show" (camelize group)) + toggleTarget=this + openLabel=(concat "Hide " group) + closedLabel=group + data-test-toggle-group=group + }} + {{#if (get this (concat "show" (camelize group)))}} +
+ {{#each fields as |attr|}} + {{form-field data-test-field attr=attr model=model}} + {{/each}} +
+ {{/if}} + {{/if}} + {{/each-in}} +{{/each}} diff --git a/ui/app/templates/partials/loading.hbs b/ui/app/templates/partials/loading.hbs new file mode 100644 index 000000000..fcf87707a --- /dev/null +++ b/ui/app/templates/partials/loading.hbs @@ -0,0 +1,14 @@ +
+
+
+
+
+ +
+
+   Loading… +
+
+
+
+
diff --git a/ui/app/templates/partials/replication/demote.hbs b/ui/app/templates/partials/replication/demote.hbs new file mode 100644 index 000000000..5b3013dbf --- /dev/null +++ b/ui/app/templates/partials/replication/demote.hbs @@ -0,0 +1,42 @@ +

+ Demote cluster +

+
+ {{#if (and + (eq replicationMode 'dr') + (not model.performance.replicationDisabled) + ) + }} +

+ Caution: Demoting this DR primary cluster + would result in a DR secondary and in that mode Vault is read-only. This + cluster is also currently operating as a Performance + {{capitalize model.performance.modeForUrl}}, demoting it will leave your + replication setup without a performance primary cluster until a new + cluster is promoted. +

+ {{/if}} +

+ Demote this {{replicationDisplayMode}} primary cluster to a {{replicationDisplayMode}} secondary. The resulting secondary cluster will not + attempt to connect to a primary, but will maintain knowledge of its cluster + ID and can be reconnected to the same set of replication clusters without + wiping local storage. +

+

+ In order to connect the resulting secondary to a new primary, use the Update primary action. +

+
+
+
+ {{#confirm-action + buttonClasses="button is-primary" + onConfirmAction=(action "onSubmit" "demote" model.replicationAttrs.modeForUrl) + confirmMessage="Are you sure you want to demote this cluster?" + confirmButtonText="Demote cluster" + cancelButtonText="Cancel" + data-test-replication-demote=true + }} + Demote cluster + {{/confirm-action}} +
+
diff --git a/ui/app/templates/partials/replication/disable.hbs b/ui/app/templates/partials/replication/disable.hbs new file mode 100644 index 000000000..be81e0ca1 --- /dev/null +++ b/ui/app/templates/partials/replication/disable.hbs @@ -0,0 +1,66 @@ +

+ Disable replication +

+
+

+ Disable {{replicationMode}} replication entirely on the cluster. + {{#if model.replicationAttrs.isPrimary}} + Any secondaries will no longer be able to connect. + {{else if (eq model.replicationAttrs.modeForUrl 'bootstrapping')}} +
+ Since the cluster is currently bootstrapping, we need to know which mode to disable. + Be sure to choose it below. + +

+
+ +
+
+ {{else}} + The cluster will no longer be able to connect to the primary. + {{/if}} +

+

+ Caution: re-enabling this node as a primary or secondary will + change its cluster ID. +

+

+ In the secondary case this means a wipe of the + underlying storage when connected to a primary, and in the primary case, + secondaries connecting back to the cluster (even if they have connected + before) will require a wipe of the underlying storage. +

+
+
+
+ {{#confirm-action + onConfirmAction=(action + "onSubmit" + "disable" + (if + (eq model.replicationAttrs.modeForUrl 'bootstrapping') + mode + model.replicationAttrs.modeForUrl + ) + ) + buttonClasses="button is-primary" + confirmMessage=(concat "Are you sure you want to disable replication on this cluster?") + confirmButtonText="Disable" + cancelButtonText="Cancel" + data-test-disable-replication=true + }} + Disable replication + {{/confirm-action}} +
+
diff --git a/ui/app/templates/partials/replication/enable.hbs b/ui/app/templates/partials/replication/enable.hbs new file mode 100644 index 000000000..cf91fd2ce --- /dev/null +++ b/ui/app/templates/partials/replication/enable.hbs @@ -0,0 +1,258 @@ + + +
+
+ {{message-error errors=errors}} + {{#if initialReplicationMode}} + {{#if (eq initialReplicationMode 'dr')}} +

+ {{i-con class="has-text-grey is-medium" glyph="replication" size=24}} + Disaster Recovery (DR) Replication +

+

+ DR is designed to protect against catastrophic failure of entire clusters. Secondaries do not forward service requests (until they are elected and become a new primary). +

+ {{else if (eq initialReplicationMode 'performance')}} +

+ {{i-con class="has-text-grey is-medium" glyph="perf-replication" size=20}} + Performance Replication +

+ {{#if (not version.hasPerfReplication)}} +

+ Performance Replication is a feature of {{#upgrade-link}}Vault Enterprise Premium{{/upgrade-link}} +

+ {{else}} +

+ Performance replication scales workloads horizontally across clusters to make requests faster. Local secondaries handle read requests but forward writes to the primary to be handled. +

+ {{/if}} + {{/if}} + {{else}} +

+ + In both Performance and Disaster Recovery (DR) Replication, secondaries share the underlying configuration, policies, and supporting secrets as their primary cluster. +

+
+
+ +
+
+ +
+
+ {{/if}} +
+
+ +
+
+ +
+ {{#if (eq mode 'secondary')}} +

+ Caution: this will immediately clear all data in this cluster! +

+ {{/if}} +
+ {{#if (eq mode 'primary')}} + {{#if cluster.canEnablePrimary}} +
+ +
+ {{input class="input" id="primary_cluster_addr" name="primary_cluster_addr" value=primary_cluster_addr}} +
+

+ Overrides the cluster address that the primary gives to secondary nodes. +

+
+ {{else}} +

+ The token you are using is not authorized to enable primary replication. +

+ {{/if}} + {{else}} + {{#if cluster.canEnableSecondary}} + {{#if (and + (eq replicationMode 'dr') + (not cluster.performance.replicationDisabled) + version.hasPerfReplication + ) + }} +
+ {{toggle-button + toggleTarget=this + toggleAttr='showExplanation' + openLabel="Disable Performance Replication in order to enable this cluster as a DR secondary." + closedLabel="Disable Performance Replication in order to enable this cluster as a DR secondary." + class="has-text-danger" + }} + {{#if showExplanation}} +

+ When running as a DR secondary Vault is read only. + For this reason, we don't allow other replication modes to operate at the same time. This cluster is also + currently operating as a Performance {{capitalize cluster.performance.modeForUrl}}. +

+ {{/if}} +
+ {{else}} +
+ +
+ {{textarea value=token id="secondary-token" name="secondary-token" class="textarea"}} +
+
+
+ +
+ {{input value=primary_api_addr id="primary_api_addr" name="primary_api_addr" class="input"}} +
+

+ {{#if (and token (not tokenIncludesAPIAddr))}} + The supplied token does not contain an embedded address for the primary cluster. Please enter the primary cluster's API address (normal Vault address). + {{else}} + Set this to the API address (normal Vault address) to override the + value embedded in the token. + {{/if}} +

+
+
+ +
+ {{input value=ca_file id="ca_file" name="ca_file" class="input"}} +
+

+ Specifies the path to a CA root file (PEM format) that the secondary can use when unwrapping the token from the primary. +

+
+
+ +
+ {{input value=ca_path id="ca_path" name="ca_file" class="input"}} +
+

+ Specifies the path to a CA root directory containing PEM-format files that the secondary can use when unwrapping the token from the primary. +

+
+

+ Note: If both CA file and CA path are not given, they default to system CA roots. +

+ {{/if}} + {{else}} +

The token you are using is not authorized to enable secondary replication.

+ {{/if}} + {{/if}} +
+ {{#if (or (and (eq mode 'primary') cluster.canEnablePrimary) (and (eq mode 'secondary') cluster.canEnableSecondary))}} +
+
+ +
+
+ {{/if}} +
diff --git a/ui/app/templates/partials/replication/mode-summary.hbs b/ui/app/templates/partials/replication/mode-summary.hbs new file mode 100644 index 000000000..bb8e3d3d0 --- /dev/null +++ b/ui/app/templates/partials/replication/mode-summary.hbs @@ -0,0 +1,32 @@ + + +
+

+ {{i-con class="has-text-grey is-medium" glyph="replication" size=20}} + Disaster Recovery (DR) +

+ {{replication-mode-summary + mode="dr" + cluster=cluster + tagName=(if cluster.dr.replicationEnabled 'a' 'div') + }} +
+
+

+ {{i-con class="has-text-grey is-medium" glyph="perf-replication" size=20}} + Performance +

+ {{replication-mode-summary + mode="performance" + cluster=cluster + tagName=(if cluster.performance.replicationEnabled 'a' 'div') + }} +
diff --git a/ui/app/templates/partials/replication/promote.hbs b/ui/app/templates/partials/replication/promote.hbs new file mode 100644 index 000000000..9d0927f33 --- /dev/null +++ b/ui/app/templates/partials/replication/promote.hbs @@ -0,0 +1,77 @@ +{{#if (and (eq replicationMode 'dr') (eq model.replicationAttrs.modeForUrl 'secondary'))}} +
+

+ This cluster is currently running as a DR Replication Secondary. + Promote the cluster to a primary by entering DR Operation Token. +

+
+ +
+ {{input class="input" id="dr_operation_token" name="dr_operation_token" value=dr_operation_token}} +
+
+
+ +
+ {{input class="input" id="primary_cluster_addr" name="primary_cluster_addr" value=primary_cluster_addr}} +
+

+ Overrides the cluster address that the primary gives to secondary nodes. +

+
+
+
+
+
+ {{#confirm-action + buttonClasses="button is-primary" + onConfirmAction=(action "onSubmit" "promote" model.replicationAttrs.modeForUrl (hash dr_operation_token=dr_operation_token primary_cluster_addr=primary_cluster_addr)) + confirmMessage="Are you sure you want to promote this cluster?" + confirmButtonText="Promote cluster" + cancelButtonText="Cancel" + }} + Promote cluster + {{/confirm-action}} +
+
+
+{{else}} +

+ Promote cluster +

+
+

Promote the cluster to primary.

+

+ Caution: Vault replication is not designed for active-active usage and enabling two primaries should never be done, as it can lead to data loss if they or their secondaries are ever reconnected. + If the cluster has a primary, be sure to demote it before promoting a secondary. +

+
+ +
+ {{input class="input" id="primary_cluster_addr" name="primary_cluster_addr" value=primary_cluster_addr}} +
+

+ Overrides the cluster address that the primary gives to secondary nodes. +

+
+
+
+
+ {{#confirm-action + buttonClasses="button is-primary" + onConfirmAction=(action "onSubmit" "promote" model.replicationAttrs.modeForUrl (hash primary_cluster_addr=primary_cluster_addr)) + confirmMessage="Are you sure you want to promote this cluster?" + confirmButtonText="Promote cluster" + cancelButtonText="Cancel" + }} + Promote cluster + {{/confirm-action}} +
+
+{{/if}} diff --git a/ui/app/templates/partials/replication/recover.hbs b/ui/app/templates/partials/replication/recover.hbs new file mode 100644 index 000000000..603edd50d --- /dev/null +++ b/ui/app/templates/partials/replication/recover.hbs @@ -0,0 +1,23 @@ +

+ Recover +

+
+

+ Attempt recovery if replication is in a bad state, for instance if an error + has caused replication to stop syncing. +

+
+
+
+ {{#confirm-action + buttonClasses="button is-primary" + onConfirmAction=(action "onSubmit" "recover") + confirmMessage="Are you sure you want to initiate cluster recovery?" + confirmButtonText="Begin recovery" + cancelButtonText="Cancel" + }} + Recover + {{/confirm-action}} +
+
+ diff --git a/ui/app/templates/partials/replication/reindex.hbs b/ui/app/templates/partials/replication/reindex.hbs new file mode 100644 index 000000000..3735a7185 --- /dev/null +++ b/ui/app/templates/partials/replication/reindex.hbs @@ -0,0 +1,22 @@ +

+ Reindex +

+
+

+ Reindex the local data storage. This can cause a very long delay depending + on the number and size of objects in the data store. +

+
+
+
+ {{#confirm-action + buttonClasses="button is-primary" + onConfirmAction=(action "onSubmit" "reindex") + confirmMessage="Are you sure you want to initiate cluster reindex?" + confirmButtonText="Begin reindex" + cancelButtonText="Cancel" + }} + Reindex + {{/confirm-action}} +
+
diff --git a/ui/app/templates/partials/replication/replication-mode-summary-menu.hbs b/ui/app/templates/partials/replication/replication-mode-summary-menu.hbs new file mode 100644 index 000000000..db4d76dc1 --- /dev/null +++ b/ui/app/templates/partials/replication/replication-mode-summary-menu.hbs @@ -0,0 +1,38 @@ +
+
+ {{#if (or replicationUnsupported (and (eq mode 'performance') (not version.hasPerfReplication)))}} +

+ Upgrade to Vault Enterprise Premium to use Performance Replication. +

+ {{else if replicationEnabled}} + + {{capitalize modeForUrl}} + + {{#if secondaryId}} + + + {{secondaryId}} + + + {{/if}} + + + {{clusterIdDisplay}} + + + {{else}} + Enable + {{/if}} +
+
+
+ {{#if replicationEnabled}} + {{#if (get cluster (concat mode 'StateGlyph'))}} + {{i-con size=14 glyph=(get cluster (concat mode 'StateGlyph')) class="has-text-info" aria-label=(concat mode 'StateDisplay')}} + {{else if syncProgress}} + + {{syncProgress.progress}} of {{syncProgress.total}} keys + + {{/if}} + {{/if}} +
diff --git a/ui/app/templates/partials/replication/replication-mode-summary.hbs b/ui/app/templates/partials/replication/replication-mode-summary.hbs new file mode 100644 index 000000000..4377a49cd --- /dev/null +++ b/ui/app/templates/partials/replication/replication-mode-summary.hbs @@ -0,0 +1,53 @@ +
+
+ {{#if (and (eq mode 'performance') (not version.hasPerfReplication))}} +

+ Performance Replication is a feature of Vault Enterprise Premium. +

+

+ {{#upgrade-link linkClass="button is-ghost has-icon-right"}} + Learn more + {{i-con glyph="chevron-right"}} + {{/upgrade-link}} +

+ {{else if replicationEnabled}} +
+ Enabled +
+ + {{capitalize modeForUrl}} + + {{#if secondaryId}} + + + {{secondaryId}} + + + {{/if}} + + + {{clusterIdDisplay}} + + + {{else}} +

+ {{#if (eq mode 'dr')}} + DR is designed to protect against catastrophic failure of entire clusters. Secondaries do not forward service requests (until they are elected and become a new primary). + {{else}} + Performance replication scales workloads horizontally across clusters to make requests faster. Local secondaries handle read requests but forward writes to the primary to be handled. + {{/if}} +

+ {{/if}} +
+
+
+ {{#if replicationDisabled}} + + Enable + + {{else if (eq mode 'dr')}} + {{cluster.drReplicationStateDisplay}} + {{else if (eq mode 'performance')}} + {{cluster.perfReplicationStateDisplay}} + {{/if}} +
diff --git a/ui/app/templates/partials/replication/update-primary.hbs b/ui/app/templates/partials/replication/update-primary.hbs new file mode 100644 index 000000000..bfc372d82 --- /dev/null +++ b/ui/app/templates/partials/replication/update-primary.hbs @@ -0,0 +1,140 @@ +{{#if (and (eq replicationMode 'dr') (eq model.replicationAttrs.modeForUrl 'secondary'))}} +
+

+ Change a secondary cluster’s assigned primary cluster using a secondary + activation token. This does not wipe all data in the cluster. +

+
+ +
+ {{input class="input" id="dr_operation_token" name="dr_operation_token" value=dr_operation_token}} +
+
+
+ +
+ {{textarea value=token id="secondary-token" name="secondary-token" class="textarea"}} +
+
+
+ +
+ {{input class="input" value=primary_api_addr id="primary_api_addr" name="primary_api_addr"}} +
+

+ Set this to the API address (normal Vault address) to override the + value embedded in the token. +

+
+
+ +
+ {{input value=ca_file id="ca_file" name="ca_file" class="input"}} +
+

+ Specifies the path to a CA root file (PEM format) that the secondary can use when unwrapping the token from the primary. +

+
+
+ +
+ {{input value=ca_path id="ca_path" name="ca_file" class="input"}} +
+

+ Specifies the path to a CA root directory containing PEM-format files that the secondary can use when unwrapping the token from the primary. +

+
+
+ +
+
+
+ {{#confirm-action + buttonClasses="button is-primary" + onConfirmAction=(action "onSubmit" "update-primary" model.replicationAttrs.modeForUrl (hash dr_operation_token=dr_operation_token token=token primary_api_addr=primary_api_addr ca_path=ca_path ca_file=ca_file)) + confirmMessage="Are you sure you want to update this cluster's primary?" + confirmButtonText="Update primary" + cancelButtonText="Cancel" + }} + Update primary + {{/confirm-action}} +
+
+
+{{else}} +

+ Update primary +

+
+

+ Change a secondary cluster’s assigned primary cluster using a secondary + activation token. This does not wipe all data in the cluster. +

+
+
+ +
+ {{textarea value=token id="secondary-token" name="secondary-token" class="textarea"}} +
+
+
+ +
+ {{input class="input" value=primary_api_addr id="primary_api_addr" name="primary_api_addr"}} +
+

+ Set this to the API address (normal Vault address) to override the + value embedded in the token. +

+
+
+ +
+ {{input value=ca_file id="ca_file" name="ca_file" class="input"}} +
+

+ Specifies the path to a CA root file (PEM format) that the secondary can use when unwrapping the token from the primary. +

+
+
+ +
+ {{input value=ca_path id="ca_path" name="ca_file" class="input"}} +
+

+ Specifies the path to a CA root directory containing PEM-format files that the secondary can use when unwrapping the token from the primary. +

+
+ +
+
+ {{#confirm-action + buttonClasses="button is-primary" + onConfirmAction=(action "onSubmit" "update-primary" model.replicationAttrs.modeForUrl (hash token=token primary_api_addr=primary_api_addr ca_path=ca_path ca_file=ca_file)) + confirmMessage="Are you sure you want to update this cluster's primary?" + confirmButtonText="Update primary" + cancelButtonText="Cancel" + }} + Update primary + {{/confirm-action}} +
+
+{{/if}} diff --git a/ui/app/templates/partials/role-aws/form.hbs b/ui/app/templates/partials/role-aws/form.hbs new file mode 100644 index 000000000..c67565198 --- /dev/null +++ b/ui/app/templates/partials/role-aws/form.hbs @@ -0,0 +1,88 @@ +{{message-error model=model}} +
+
+ {{#if (eq mode 'create')}} +
+ +
+ {{input id="name" value=model.id class="input" data-test-input="name"}} +
+
+ {{/if}} +
+
+
+ {{#if useARN}} + + {{else}} + + {{/if}} +
+
+
+ {{input + data-test-aws-toggle-use-arn=true + id="use-arn" + type="checkbox" + name="use-arn" + class="switch is-rounded is-success is-small" + checked=useARN + }} + +
+
+
+
+ {{#if useARN}} + {{input id="arn" value=model.arn class="input" data-test-input="arn"}} + {{else}} + {{json-editor + value=(if model.policy (stringify (jsonify model.policy)) emptyData) + valueUpdated=(action "codemirrorUpdated" "policy") + }} + {{/if}} +
+
+
+
+
+ {{#if capabilities.canCreate}} + + {{/if}} + {{#secret-link + mode=(if (eq mode "create") "list" "show") + class="button" + secret=model.id + }} + Cancel + {{/secret-link}} +
+ {{#if (and (eq mode 'edit') model.canDelete)}} + {{#confirm-action + buttonClasses="button" + onConfirmAction=(action "delete") + confirmMessage=(concat "Are you sure you want to delete " model.id "?") + cancelButtonText="Cancel" + }} + Delete + {{/confirm-action}} + {{/if}} +
+
diff --git a/ui/app/templates/partials/role-aws/popup-menu.hbs b/ui/app/templates/partials/role-aws/popup-menu.hbs new file mode 100644 index 000000000..365ba3d55 --- /dev/null +++ b/ui/app/templates/partials/role-aws/popup-menu.hbs @@ -0,0 +1,76 @@ +{{#popup-menu name="role-aws-nav" contentClass="is-wide"}} + +{{/popup-menu}} diff --git a/ui/app/templates/partials/role-aws/show.hbs b/ui/app/templates/partials/role-aws/show.hbs new file mode 100644 index 000000000..9f396d59b --- /dev/null +++ b/ui/app/templates/partials/role-aws/show.hbs @@ -0,0 +1,11 @@ +
+ {{#each model.attrs as |attr|}} + {{#if (eq attr.name "policy")}} + {{#info-table-row label=(capitalize (or attr.options.label (humanize (dasherize attr.name)))) value=model.policy}} +
{{stringify (jsonify model.policy)}}
+ {{/info-table-row}} + {{else}} + {{info-table-row label=(capitalize (or attr.options.label (humanize (dasherize attr.name)))) value=(get model attr.name)}} + {{/if}} + {{/each}} +
diff --git a/ui/app/templates/partials/role-pki/form.hbs b/ui/app/templates/partials/role-pki/form.hbs new file mode 100644 index 000000000..2f3c9e82a --- /dev/null +++ b/ui/app/templates/partials/role-pki/form.hbs @@ -0,0 +1,42 @@ +{{message-error model=model}} +
+
+ {{partial "partials/form-field-groups-loop"}} +
+
+
+ {{#if capabilities.canCreate}} + + {{/if}} + {{#secret-link + mode=(if (eq mode "create") "list" "show") + class="button" + secret=model.id + }} + Cancel + {{/secret-link}} +
+ {{#if (and (eq mode 'edit') model.canDelete)}} + {{#confirm-action + data-test-role-delete + buttonClasses="button" + onConfirmAction=(action "delete") + confirmMessage=(concat "Are you sure you want to delete " model.id "?") + cancelButtonText="Cancel" + }} + Delete + {{/confirm-action}} + {{/if}} +
+
diff --git a/ui/app/templates/partials/role-pki/popup-menu.hbs b/ui/app/templates/partials/role-pki/popup-menu.hbs new file mode 100644 index 000000000..3e3d41569 --- /dev/null +++ b/ui/app/templates/partials/role-pki/popup-menu.hbs @@ -0,0 +1,76 @@ +{{#popup-menu name="role-aws-nav" contentClass="is-wide"}} + +{{/popup-menu}} diff --git a/ui/app/templates/partials/role-pki/show.hbs b/ui/app/templates/partials/role-pki/show.hbs new file mode 100644 index 000000000..b5062b0b3 --- /dev/null +++ b/ui/app/templates/partials/role-pki/show.hbs @@ -0,0 +1,20 @@ +
+ {{#each model.fieldGroups as |fieldGroup|}} + {{#each-in fieldGroup as |group fields|}} + {{#if (or (eq group "default") (eq group "Options"))}} + {{#each fields as |attr|}} + {{info-table-row label=(capitalize (or attr.options.label (humanize (dasherize attr.name)))) value=(get model attr.name)}} + {{/each}} + {{else}} +
+

+ {{group}} +

+ {{#each fields as |attr|}} + {{info-table-row alwaysRender=true label=(capitalize (or attr.options.label (humanize (dasherize attr.name)))) value=(get model attr.name)}} + {{/each}} +
+ {{/if}} + {{/each-in}} + {{/each}} +
diff --git a/ui/app/templates/partials/role-ssh/form.hbs b/ui/app/templates/partials/role-ssh/form.hbs new file mode 100644 index 000000000..cd03090df --- /dev/null +++ b/ui/app/templates/partials/role-ssh/form.hbs @@ -0,0 +1,61 @@ +{{message-error model=model}} +
+
+ {{#with (if (eq model.keyType 'otp') 3 4) as |numRequired|}} + {{#each (take numRequired model.attrsForKeyType) as |attr|}} + {{#unless (and (eq mode 'edit') (eq attr.name 'name'))}} + {{partial "partials/form-field-from-model"}} + {{/unless}} + {{/each}} + {{toggle-button + toggleAttr="showOptions" + toggleTarget=this + openLabel="Hide options" + closedLabel="More options" + data-test-toggle-more="true" + }} + {{#if showOptions}} +
+ {{#each (drop numRequired model.attrsForKeyType) as |attr|}} + {{partial "partials/form-field-from-model"}} + {{/each}} +
+ {{/if}} + {{/with}} +
+
+
+ {{#if capabilities.canCreate}} + + {{/if}} + {{#secret-link + mode=(if (eq mode "create") "list" "show") + class="button" + secret=model.id + }} + Cancel + {{/secret-link}} +
+ {{#if (and (eq mode 'edit') model.canDelete)}} + {{#confirm-action + buttonClasses="button" + onConfirmAction=(action "delete") + confirmMessage=(concat "Are you sure you want to delete " model.id "?") + cancelButtonText="Cancel" + }} + Delete + {{/confirm-action}} + {{/if}} +
+
diff --git a/ui/app/templates/partials/role-ssh/popup-menu.hbs b/ui/app/templates/partials/role-ssh/popup-menu.hbs new file mode 100644 index 000000000..d90b47444 --- /dev/null +++ b/ui/app/templates/partials/role-ssh/popup-menu.hbs @@ -0,0 +1,95 @@ +{{#popup-menu name="role-ssh-nav"}} + +{{/popup-menu}} diff --git a/ui/app/templates/partials/role-ssh/show.hbs b/ui/app/templates/partials/role-ssh/show.hbs new file mode 100644 index 000000000..0caee6f21 --- /dev/null +++ b/ui/app/templates/partials/role-ssh/show.hbs @@ -0,0 +1,9 @@ +
+ {{#each model.attrsForKeyType as |attr|}} + {{#if (eq attr.type "object")}} + {{info-table-row label=(capitalize (or attr.options.label (humanize (dasherize attr.name)))) value=(stringify (get model attr.name))}} + {{else}} + {{info-table-row label=(capitalize (or attr.options.label (humanize (dasherize attr.name)))) value=(get model attr.name)}} + {{/if}} + {{/each}} +
diff --git a/ui/app/templates/partials/secret-backend-settings/aws.hbs b/ui/app/templates/partials/secret-backend-settings/aws.hbs new file mode 100644 index 000000000..ac23e95db --- /dev/null +++ b/ui/app/templates/partials/secret-backend-settings/aws.hbs @@ -0,0 +1,120 @@ +
+ +
+{{#if (eq tab "leases")}} +
+
+ {{message-error model=model}} +

+ If you do not supply lease settings, we will use the default values in AWS. +

+
+ {{ttl-picker labelText="Lease" initialValue=model.lease onChange=(action (mut model.lease))}} + {{ttl-picker labelText="Maximum Lease" initialValue=model.leaseMax onChange=(action (mut model.leaseMax))}} +
+ +
+
+{{else}} +
+
+

+ Note: the client uses the official AWS SDK and will use the specified credentials, environment credentials, shared file credentials, or IAM role/ECS task credentials in that order. +

+
+ +
+ +
+ {{input type="text" id="access" name="access" class="input" value=accessKey data-test-aws-input="accessKey"}} +
+
+ +
+ +
+ {{input type="text" id="secret" name="secret" class="input" value=secretKey data-test-aws-input="secretKey"}} +
+
+ + {{toggle-button + toggleAttr="showOptions" + toggleTarget=this + openLabel="Hide options" + closedLabel="More options" + }} + {{#if showOptions}} +
+
+ +
+
+ +
+
+
+
+ +
+ {{input type="text" id="iam" name="iam" class="input" value=iamEndpoint}} +
+
+
+ +
+ {{input type="text" id="sts" name="sts" class="input" value=stsEndpoint}} +
+
+
+ {{/if}} + +
+ +
+
+{{/if}} + diff --git a/ui/app/templates/partials/secret-backend-settings/pki.hbs b/ui/app/templates/partials/secret-backend-settings/pki.hbs new file mode 100644 index 000000000..b9a3d030a --- /dev/null +++ b/ui/app/templates/partials/secret-backend-settings/pki.hbs @@ -0,0 +1,21 @@ +
+ +
diff --git a/ui/app/templates/partials/secret-backend-settings/ssh.hbs b/ui/app/templates/partials/secret-backend-settings/ssh.hbs new file mode 100644 index 000000000..72f7dfea7 --- /dev/null +++ b/ui/app/templates/partials/secret-backend-settings/ssh.hbs @@ -0,0 +1,84 @@ +{{#if configured}} +
+
+ +
+ {{textarea + name="publicKey" + id="publicKey" + class="textarea" + value=model.publicKey + readonly=true + data-test-ssh-input="public-key" + }} +
+
+
+
+
+ {{#copy-button + clipboardTarget="#publicKey" + class="button is-primary" + buttonType="button" + success=(action (set-flash-message "Public Key copied!")) + }} + Copy + {{/copy-button}} +
+
+ {{#confirm-action + buttonClasses="button" + onConfirmAction=(action "saveConfig" (hash delete=true)) + confirmMessage=(concat "This will delete the current CA keys associated with this backend. Are you sure you want to delete them?") + cancelButtonText="Cancel" + }} + Delete + {{/confirm-action}} +
+
+{{else}} +
+
+
+ +
+ {{textarea name="privateKey" id="privateKey" class="input" value=model.privateKey}} +
+
+
+ +
+ {{textarea name="publicKey" id="publicKey" class="input" value=model.publicKey}} +
+
+
+ + +
+
+
+
+ +
+
+
+{{/if}} diff --git a/ui/app/templates/partials/secret-edit-display.hbs b/ui/app/templates/partials/secret-edit-display.hbs new file mode 100644 index 000000000..7f44152de --- /dev/null +++ b/ui/app/templates/partials/secret-edit-display.hbs @@ -0,0 +1,52 @@ +{{#unless key.isFolder}} + {{#if showAdvancedMode}} + {{json-editor + value=codemirrorString + valueUpdated=(action "codemirrorUpdated") + onFocusOut=(action "formatJSON") + }} + {{else}} + {{#each secretData as |secret index|}} +
+
+ {{input + data-test-secret-key=true + value=secret.name + placeholder="key" + change="handleChange" + class="input" + }} +
+
+ {{textarea + data-test-secret-value=true + name=secret.name + key-down="handleKeyDown" + change="handleChange" + value=secret.value + wrap="off" + class="input" + placeholder="value" + rows=1 + }} +
+
+ {{#if (eq secretData.length (inc index))}} + + {{else}} + + {{/if}} +
+
+ {{/each}} + {{/if}} +{{/unless}} diff --git a/ui/app/templates/partials/secret-form-create.hbs b/ui/app/templates/partials/secret-form-create.hbs new file mode 100644 index 000000000..2f858f587 --- /dev/null +++ b/ui/app/templates/partials/secret-form-create.hbs @@ -0,0 +1,58 @@ +{{message-error model=key}} +
+
+ +
+ {{#if (not-eq key.initialParentKey '') }} + {{! need this to prevent a shift in the layout before we transition when saving }} + {{#if key.isCreating}} +

+ +

+ {{else}} +

+ +

+ {{/if}} + {{/if}} +

+ {{input data-test-secret-path=true id="kv-key" class="input" value=key.keyWithoutParent}} +

+
+ {{#if key.isFolder}} +

+ The secret path may not end in / +

+ {{/if}} +
+ + {{partial "partials/secret-edit-display"}} + +
+
+ {{#if capabilities.canCreate}} + + {{/if}} +
+
+ {{#secret-link + mode="list" + secret=key.initialParentKey + class="button" + }} + Cancel + {{/secret-link}} +
+
+
diff --git a/ui/app/templates/partials/secret-form-edit.hbs b/ui/app/templates/partials/secret-form-edit.hbs new file mode 100644 index 000000000..fabb751fa --- /dev/null +++ b/ui/app/templates/partials/secret-form-edit.hbs @@ -0,0 +1,96 @@ +{{#if key.isError}} + {{#each key.adapterError.errors as |error|}} +
+ {{error}} +
+ {{/each}} +{{/if}} + +
+ {{#if key.didError}} +
+

+ We were unable to save your changes because the key has changed since the page loaded. Reload the page or click the button below to try again. +

+ +
+ {{/if}} + + {{#unless showAdvancedMode}} +
+
+
+ Key +
+
+ Value +
+
+
+ {{/unless}} + {{#if capabilities.canUpdate}} + {{partial "partials/secret-edit-display"}} + {{else}} +
+
+

Your current token does not have capabilities to update this secret.

+

+ {{#secret-link + mode=(if key.isFolder "list" "show") + secret=key.id + class="button" + }} + Back to {{key.id}} + {{/secret-link}} +

+
+
+ {{/if}} + +
+
+ {{#unless key.isFolder}} + {{#if capabilities.canUpdate}} +
+ +
+ {{/if}} + {{/unless}} +
+ {{#secret-link + mode=(if key.isFolder "list" "show") + secret=key.id + class="button" + }} + Cancel + {{/secret-link}} +
+
+ {{#if capabilities.canDelete}} + {{#confirm-action + buttonClasses="button" + onConfirmAction=(action "deleteKey") + confirmMessage=(if key.isFolder + (concat "Are you sure you want to delete " key.id " and all its contents?") + (concat "Are you sure you want to delete " key.id "?") + ) + cancelButtonText="Cancel" + data-test-secret-delete="true" + }} + {{#if key.isFolder}} + Delete folder + {{else}} + Delete secret + {{/if}} + {{/confirm-action}} + {{/if}} +
+
diff --git a/ui/app/templates/partials/secret-form-show.hbs b/ui/app/templates/partials/secret-form-show.hbs new file mode 100644 index 000000000..6622ff48f --- /dev/null +++ b/ui/app/templates/partials/secret-form-show.hbs @@ -0,0 +1,22 @@ +{{#if showAdvancedMode}} + {{json-editor + value=key.dataAsJSONString + options=(hash + readOnly=true + ) + }} +{{else}} +
+
+
+ Key +
+
+ Value +
+
+
+ {{#each-in key.secretData as |key value|}} + {{info-table-row label=key value=value alwaysRender=true}} + {{/each-in}} +{{/if}} diff --git a/ui/app/templates/partials/secret-list/aws-role-item.hbs b/ui/app/templates/partials/secret-list/aws-role-item.hbs new file mode 100644 index 000000000..9a83e7149 --- /dev/null +++ b/ui/app/templates/partials/secret-list/aws-role-item.hbs @@ -0,0 +1,37 @@ +{{#linked-block + (concat + "vault.cluster.secrets.backend." + "credentials" + (if (not item.id) "-root") + ) + item.id + class="box is-sideless is-marginless" + data-test-secret-link=item.id +}} +
+
+ {{#link-to + (concat + "vault.cluster.secrets.backend." + "credentials" + (if (not item.id) "-root") + ) + item.id + class="has-text-black has-text-weight-semibold" + }} + {{i-con + glyph="role" + size=14 + class="has-text-grey-light is-pulled-left" + }} +
+ {{if (eq item.id ' ') '(self)' (or item.keyWithoutParent item.id)}} +
+ {{/link-to}} +
+
+ {{partial 'partials/role-pki/popup-menu'}} +
+
+{{/linked-block}} + diff --git a/ui/app/templates/partials/secret-list/item.hbs b/ui/app/templates/partials/secret-list/item.hbs new file mode 100644 index 000000000..8baa37315 --- /dev/null +++ b/ui/app/templates/partials/secret-list/item.hbs @@ -0,0 +1,28 @@ +{{#linked-block + (concat + "vault.cluster.secrets.backend." + (if item.isFolder "list" "show") + (if (not item.id) "-root") + ) + item.id + class="box is-sideless is-marginless" + data-test-secret-link=item.id +}} +
+
+ {{#secret-link + mode=(if item.isFolder "list" "show") + secret=item.id + class="has-text-black has-text-weight-semibold" + }} + {{i-con + glyph=(if item.isFolder 'folder' 'file') + size=14 + class="has-text-grey-light" + }}{{if (eq item.id ' ') '(self)' (or item.keyWithoutParent item.id)}} + {{/secret-link}} +
+
+
+
+{{/linked-block}} diff --git a/ui/app/templates/partials/secret-list/pki-cert-item.hbs b/ui/app/templates/partials/secret-list/pki-cert-item.hbs new file mode 100644 index 000000000..d2a32e6ec --- /dev/null +++ b/ui/app/templates/partials/secret-list/pki-cert-item.hbs @@ -0,0 +1,37 @@ +{{#linked-block + (concat + "vault.cluster.secrets.backend." + "show" + (if (not item.id) "-root") + ) + item.idForNav + class="box is-sideless is-marginless linked-block" + data-test-secret-link=item.id + tagName="div" +}} +
+
+ {{#link-to + (concat + "vault.cluster.secrets.backend." + "show" + (if (not item.id) "-root") + ) + item.idForNav + class="has-text-black has-text-weight-semibold" + }} + {{i-con + glyph="file" + size=14 + class="has-text-grey-light is-pulled-left" + }} +
+ {{if (eq item.id " ") "(self)" (or item.keyWithoutParent item.id)}} +
+ {{/link-to}} +
+
+ {{pki-cert-popup item=item}} +
+
+{{/linked-block}} diff --git a/ui/app/templates/partials/secret-list/pki-role-item.hbs b/ui/app/templates/partials/secret-list/pki-role-item.hbs new file mode 100644 index 000000000..7c5624185 --- /dev/null +++ b/ui/app/templates/partials/secret-list/pki-role-item.hbs @@ -0,0 +1,40 @@ +{{#linked-block + (concat + "vault.cluster.secrets.backend." + "credentials" + (if (not item.id) "-root") + ) + item.backend + item.id + queryParams=(hash action="issue") + class="box is-sideless is-marginless" + data-test-secret-link=item.id + tagName="div" +}} +
+
+ {{#link-to + (concat + "vault.cluster.secrets.backend." + "credentials" + (if (not item.id) "-root") + ) + item.id + (query-params action="issue") + class="has-text-black has-text-weight-semibold" + }} + {{i-con + glyph="role" + size=14 + class="has-text-grey-light is-pulled-left" + }} +
+ {{if (eq item.id " ") "(self)" (or item.keyWithoutParent item.id)}} +
+ {{/link-to}} +
+
+ {{partial "partials/role-pki/popup-menu"}} +
+
+{{/linked-block}} diff --git a/ui/app/templates/partials/secret-list/ssh-role-item.hbs b/ui/app/templates/partials/secret-list/ssh-role-item.hbs new file mode 100644 index 000000000..0928ab6ae --- /dev/null +++ b/ui/app/templates/partials/secret-list/ssh-role-item.hbs @@ -0,0 +1,44 @@ +{{#linked-block + (concat + "vault.cluster.secrets.backend." + (if (eq item.keyType "ca") "sign" "credentials") + (if (not item.id) "-root") + ) + item.id + class="box is-sideless is-marginless" + data-test-secret-link=item.id +}} +
+
+ {{#link-to + (concat + "vault.cluster.secrets.backend." + (if (eq item.keyType "ca") "sign" "credentials") + (if (not item.id) "-root") + ) + item.id + class="has-text-black has-text-weight-semibold" + }} + {{i-con + glyph="role" + size=14 + class="has-text-grey-light is-pulled-left" + }} +
+ {{if (eq item.id ' ') '(self)' (or item.keyWithoutParent item.id)}} +
+ {{item.keyType}} + {{#if item.zeroAddress}} + Zero-Address + {{/if}} +
+ {{/link-to}} +
+
+ {{#if (eq backendType 'ssh')}} + {{partial 'partials/role-ssh/popup-menu'}} + {{/if}} +
+
+{{/linked-block}} + diff --git a/ui/app/templates/partials/status/cluster.hbs b/ui/app/templates/partials/status/cluster.hbs new file mode 100644 index 000000000..91d0c7b5b --- /dev/null +++ b/ui/app/templates/partials/status/cluster.hbs @@ -0,0 +1,12 @@ +
+
+

+ In case of emergency +

+
+
+
+

If you suspect your data has been compromised, you can seal your vault to prevent access to your secrets.

+
+
+
diff --git a/ui/app/templates/partials/status/replication.hbs b/ui/app/templates/partials/status/replication.hbs new file mode 100644 index 000000000..be58af87e --- /dev/null +++ b/ui/app/templates/partials/status/replication.hbs @@ -0,0 +1,28 @@ +
+ +
diff --git a/ui/app/templates/partials/status/user.hbs b/ui/app/templates/partials/status/user.hbs new file mode 100644 index 000000000..575aaeab2 --- /dev/null +++ b/ui/app/templates/partials/status/user.hbs @@ -0,0 +1,3 @@ +{{#if (and cluster.name auth.currentToken)}} + {{auth-info activeClusterName=cluster.name}} +{{/if}} diff --git a/ui/app/templates/partials/tools/hash.hbs b/ui/app/templates/partials/tools/hash.hbs new file mode 100644 index 000000000..6c93f454e --- /dev/null +++ b/ui/app/templates/partials/tools/hash.hbs @@ -0,0 +1,100 @@ + + +{{#if sum}} +
+
+ +
+ +
+
+
+
+
+ {{#copy-button + clipboardText=sum + class="button is-primary" + buttonType="button" + success=(action (set-flash-message 'Hashed data copied!')) + }} + Copy + {{/copy-button}} +
+
+ +
+
+{{else}} +
+
+ +
+ {{textarea id="input" name="input" value=input class="textarea" data-test-tools-input="hash-input"}} + {{b64-toggle value=input isInput=false data-test-tools-b64-toggle="input"}} +
+
+
+
+
+ +
+
+ +
+
+
+
+ +
+
+ +
+
+
+
+
+
+
+
+ +
+
+{{/if}} diff --git a/ui/app/templates/partials/tools/lookup.hbs b/ui/app/templates/partials/tools/lookup.hbs new file mode 100644 index 000000000..875281d3d --- /dev/null +++ b/ui/app/templates/partials/tools/lookup.hbs @@ -0,0 +1,49 @@ + + +{{#if (or creation_time creation_ttl)}} +
+ {{info-table-row label="Creation time" value=creation_time data-test-tools="token-lookup-row"}} + {{info-table-row label="Creation TTL" value=creation_ttl data-test-tools="token-lookup-row"}} + {{#if expirationDate}} + {{info-table-row label="Expiration date" value=expirationDate data-test-tools="token-lookup-row"}} + {{info-table-row label="Expires in" value=(moment-from-now expirationDate interval=1000 hideSuffix=true) data-test-tools="token-lookup-row"}} + {{/if}} +
+
+
+ +
+
+{{else}} +
+
+ +
+ {{input + value=token + class="input" + id="token" + name="token" + data-test-tools-input="wrapping-token" + }} +
+
+
+
+
+ +
+
+{{/if}} diff --git a/ui/app/templates/partials/tools/random.hbs b/ui/app/templates/partials/tools/random.hbs new file mode 100644 index 000000000..709aea780 --- /dev/null +++ b/ui/app/templates/partials/tools/random.hbs @@ -0,0 +1,79 @@ + + +{{#if random_bytes}} +
+ + +
+
+
+ {{#copy-button + clipboardText=random_bytes + class="button is-primary" + buttonType="button" + success=(action (set-flash-message 'Random bytes copied!')) + }} + Copy + {{/copy-button}} +
+
+ +
+
+{{else}} +
+
+
+
+ +
+ {{input id="bytes" class="input" value=bytes data-test-tools-input="bytes"}} +
+
+
+ +
+
+ +
+
+
+
+
+
+
+
+ +
+
+{{/if}} diff --git a/ui/app/templates/partials/tools/rewrap.hbs b/ui/app/templates/partials/tools/rewrap.hbs new file mode 100644 index 000000000..e3bb30c89 --- /dev/null +++ b/ui/app/templates/partials/tools/rewrap.hbs @@ -0,0 +1,66 @@ + + +{{#if rewrap_token}} +
+
+ +
+ {{textarea + value=rewrap_token + readonly=true + class="textarea" + id="wrap-info" + name="wrap-info" + data-test-tools-input="rewrapped-token" + }} +
+
+
+
+
+ {{#copy-button + clipboardText=rewrap_token + class="button is-primary" + buttonType="button" + success=(action (set-flash-message 'Token copied!')) + }} + Copy + {{/copy-button}} +
+
+ +
+
+{{else}} +
+
+ +
+ {{input + value=token + class="input" + id="token" + name="token" + data-test-tools-input="wrapping-token" + }} +
+
+
+
+
+ +
+
+{{/if}} diff --git a/ui/app/templates/partials/tools/unwrap.hbs b/ui/app/templates/partials/tools/unwrap.hbs new file mode 100644 index 000000000..fb1ecf428 --- /dev/null +++ b/ui/app/templates/partials/tools/unwrap.hbs @@ -0,0 +1,66 @@ + + +{{#if unwrap_data}} +
+
+ +
+ {{json-editor + value=(stringify unwrap_data) + options=(hash + readOnly=true + ) + }} +
+
+
+
+
+ {{#copy-button + clipboardText=(stringify unwrap_data) + class="button is-primary" + buttonType="button" + success=(action (set-flash-message 'Data copied!')) + }} + Copy + {{/copy-button}} +
+
+ +
+
+{{else}} +
+
+ +
+ {{input + value=token + class="input" + id="token" + name="token" + data-test-tools-input="wrapping-token" + }} +
+
+
+
+
+ +
+
+{{/if}} diff --git a/ui/app/templates/partials/tools/wrap.hbs b/ui/app/templates/partials/tools/wrap.hbs new file mode 100644 index 000000000..47643ca97 --- /dev/null +++ b/ui/app/templates/partials/tools/wrap.hbs @@ -0,0 +1,64 @@ + + +{{#if token}} +
+
+ +
+ {{textarea + value=token + readonly=true + class="textarea" + id="wrap-info" + name="wrap-info" + data-test-tools-input="wrapping-token" + }} +
+
+
+
+
+ {{#copy-button + clipboardText=token + class="button is-primary" + buttonType="button" + success=(action (set-flash-message 'Token copied!')) + }} + Copy + {{/copy-button}} +
+
+ +
+
+{{else}} +
+
+ +
+ {{json-editor + value=data + valueUpdated=(action "codemirrorUpdated") + }} +
+
+ {{ttl-picker labelText='Wrap TTL' onChange=(action (mut wrapTTL))}} +
+
+
+ +
+
+{{/if}} diff --git a/ui/app/templates/partials/transit-form-create.hbs b/ui/app/templates/partials/transit-form-create.hbs new file mode 100644 index 000000000..5d90a8fc3 --- /dev/null +++ b/ui/app/templates/partials/transit-form-create.hbs @@ -0,0 +1,118 @@ +{{message-error model=key}} +
+
+
+ + {{input id="key-name" value=key.id class="input" data-test-transit-key-name=true}} +
+
+ +
+
+ +
+
+
+
+
+ + +
+
+ {{#if (or + (eq key.type "aes256-gcm96") + (eq key.type "chacha20-poly1305") + (eq key.type "ed25519") + ) + }} +
+
+ + +
+
+ {{/if}} + {{#if (or + (eq key.type "aes256-gcm96") + (eq key.type "chacha20-poly1305") + ) + }} +
+
+ + +
+
+ {{/if}} +
+
+ {{#if capabilities.canCreate}} +
+ +
+ {{/if}} +
+ {{#secret-link + mode="list" + class="button" + }} + Cancel + {{/secret-link}} +
+
+
diff --git a/ui/app/templates/partials/transit-form-edit.hbs b/ui/app/templates/partials/transit-form-edit.hbs new file mode 100644 index 000000000..ba79137b2 --- /dev/null +++ b/ui/app/templates/partials/transit-form-edit.hbs @@ -0,0 +1,103 @@ +{{message-error model=key}} +
+
+
+
+ + +
+
+
+ +
+
+ +
+
+

+ The minimum decryption version required to reverse transformations performed with the encryption key. Results from lower key versions + may be rewrapped with the new key version using the rewrap action. +

+
+
+ +
+
+ +
+
+

+ The minimum version of the key that can be used to encrypt plaintext, sign payloads, or generate HMACs. + You will be able to specify which version of the key to use for each of these actions. The value specified here must be greater than or equal to that specified in the Minimum Decryption Version selection above. +

+
+
+
+
+ {{#if capabilities.canUpdate}} +
+ +
+ {{/if}} +
+ {{#secret-link + mode="show" + secret=key.id + class="button" + }} + Cancel + {{/secret-link}} +
+
+ {{#if (and key.canDelete capabilities.canDelete)}} + {{#confirm-action + buttonClasses="button" + onConfirmAction=(action "deleteKey") + confirmMessage=(concat "Are you sure you want to delete " key.id "?") + cancelButtonText="Cancel" + }} + Delete encryption key + {{/confirm-action}} + {{/if}} +
+
diff --git a/ui/app/templates/partials/transit-form-show.hbs b/ui/app/templates/partials/transit-form-show.hbs new file mode 100644 index 000000000..4996a065a --- /dev/null +++ b/ui/app/templates/partials/transit-form-show.hbs @@ -0,0 +1,177 @@ +
+ +
+ +{{#if (eq tab 'versions')}} + {{#if (or + (eq key.type "aes256-gcm96") + (eq key.type "chacha20-poly1305") + ) + }} +
+
+
+ Version +
+
+
+
+ Created at +
+
+
+ {{#each-in key.keys as |version creationTimestamp|}} +
+
+
+ {{version}} + {{#if (coerce-eq key.minDecryptionVersion version)}} +

(current minimum decryption version)

+ {{/if}} +
+
+
+
+ {{moment-format (unix creationTimestamp) 'MMM DD, YYYY hh:mm:ss A'}} +
+ + {{moment-format (unix creationTimestamp) }} + +
+
+
+ {{/each-in}} + {{else}} +
+
+
+
+
+ Version +
+
+
+
+ Name +
+
+
+
+ Created at +
+
+
+
+
+
+
+
+
+
+ {{#each-in key.keys as |version meta|}} +
+
+
+
+
+
+
+ {{version}} + {{#if (coerce-eq key.minDecryptionVersion version)}} +

(current minimum decryption version)

+ {{/if}} +
+
+
+
+ {{meta.name}} +
+
+
+
+
+ {{moment-format meta.creation_time 'MMM DD, YYYY hh:mm:ss A'}} +
+ + {{moment-format meta.creation_time}} + +
+
+
+
+
+
+ +
+
+
+
+ {{#if (get this (concat version '-open'))}} +
+
+
+ + Public Key + +
{{meta.public_key}}
+
+
+ {{#copy-button + clipboardText=meta.public_key + class="button" + buttonType="button" + success=(action (set-flash-message (concat 'Public key for version ' version ' copied!'))) + }} + Copy + {{/copy-button}} +
+
+
+
+
+ {{/if}} + {{/each-in}} + {{/if}} +

+ {{transit-key-actions + key=key + selectedAction='rotate' + capabilities=capabilities + }} +

+{{else}} + {{info-table-row label="Type" value=key.type}} + {{info-table-row label="Deletion allowed" value=(stringify key.deletionAllowed)}} + + {{#if key.derived}} + {{info-table-row label="Derived" value=key.derived}} + {{info-table-row label="Convergent encryption" value=key.convergentEncryption}} + {{/if}} +{{/if}} diff --git a/ui/app/templates/partials/upgrade-overlay.hbs b/ui/app/templates/partials/upgrade-overlay.hbs new file mode 100644 index 000000000..678a92de2 --- /dev/null +++ b/ui/app/templates/partials/upgrade-overlay.hbs @@ -0,0 +1,78 @@ + diff --git a/ui/app/templates/partials/userpass-form.hbs b/ui/app/templates/partials/userpass-form.hbs new file mode 100644 index 000000000..ec1945a29 --- /dev/null +++ b/ui/app/templates/partials/userpass-form.hbs @@ -0,0 +1,28 @@ +
+
+ +
+ {{input + value=username + name="username" + id="username" + class="input" + }} +
+
+
+ +
+ {{input + value=password + name="password" + id="password" + type="password" + class="input" + }} +
+
+
diff --git a/ui/app/templates/svg/edition-icon-premium.hbs b/ui/app/templates/svg/edition-icon-premium.hbs new file mode 100644 index 000000000..772fda857 --- /dev/null +++ b/ui/app/templates/svg/edition-icon-premium.hbs @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui/app/templates/svg/edition-icon-pro.hbs b/ui/app/templates/svg/edition-icon-pro.hbs new file mode 100644 index 000000000..c108d1c3a --- /dev/null +++ b/ui/app/templates/svg/edition-icon-pro.hbs @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/ui/app/templates/svg/hashicorp-logo.hbs b/ui/app/templates/svg/hashicorp-logo.hbs new file mode 100644 index 000000000..47c283313 --- /dev/null +++ b/ui/app/templates/svg/hashicorp-logo.hbs @@ -0,0 +1,4 @@ + + + + diff --git a/ui/app/templates/svg/icons/alert-circled.hbs b/ui/app/templates/svg/icons/alert-circled.hbs new file mode 100644 index 000000000..331d6d3f0 --- /dev/null +++ b/ui/app/templates/svg/icons/alert-circled.hbs @@ -0,0 +1 @@ + diff --git a/ui/app/templates/svg/icons/alert.hbs b/ui/app/templates/svg/icons/alert.hbs new file mode 100644 index 000000000..69ebb096a --- /dev/null +++ b/ui/app/templates/svg/icons/alert.hbs @@ -0,0 +1 @@ + diff --git a/ui/app/templates/svg/icons/android-folder-open.hbs b/ui/app/templates/svg/icons/android-folder-open.hbs new file mode 100644 index 000000000..f1c668bab --- /dev/null +++ b/ui/app/templates/svg/icons/android-folder-open.hbs @@ -0,0 +1 @@ + diff --git a/ui/app/templates/svg/icons/android-folder.hbs b/ui/app/templates/svg/icons/android-folder.hbs new file mode 100644 index 000000000..e64916df9 --- /dev/null +++ b/ui/app/templates/svg/icons/android-folder.hbs @@ -0,0 +1 @@ + diff --git a/ui/app/templates/svg/icons/android-person.hbs b/ui/app/templates/svg/icons/android-person.hbs new file mode 100644 index 000000000..211977bbd --- /dev/null +++ b/ui/app/templates/svg/icons/android-person.hbs @@ -0,0 +1 @@ + diff --git a/ui/app/templates/svg/icons/android-sync.hbs b/ui/app/templates/svg/icons/android-sync.hbs new file mode 100644 index 000000000..39d936113 --- /dev/null +++ b/ui/app/templates/svg/icons/android-sync.hbs @@ -0,0 +1 @@ + diff --git a/ui/app/templates/svg/icons/checkmark-circled.hbs b/ui/app/templates/svg/icons/checkmark-circled.hbs new file mode 100644 index 000000000..c8bfac62b --- /dev/null +++ b/ui/app/templates/svg/icons/checkmark-circled.hbs @@ -0,0 +1 @@ + diff --git a/ui/app/templates/svg/icons/checkmark.hbs b/ui/app/templates/svg/icons/checkmark.hbs new file mode 100644 index 000000000..75f955703 --- /dev/null +++ b/ui/app/templates/svg/icons/checkmark.hbs @@ -0,0 +1 @@ + diff --git a/ui/app/templates/svg/icons/chevron-down.hbs b/ui/app/templates/svg/icons/chevron-down.hbs new file mode 100644 index 000000000..aa22de866 --- /dev/null +++ b/ui/app/templates/svg/icons/chevron-down.hbs @@ -0,0 +1 @@ + diff --git a/ui/app/templates/svg/icons/chevron-left.hbs b/ui/app/templates/svg/icons/chevron-left.hbs new file mode 100644 index 000000000..3a5ee52c6 --- /dev/null +++ b/ui/app/templates/svg/icons/chevron-left.hbs @@ -0,0 +1 @@ + diff --git a/ui/app/templates/svg/icons/chevron-right.hbs b/ui/app/templates/svg/icons/chevron-right.hbs new file mode 100644 index 000000000..adaf018d5 --- /dev/null +++ b/ui/app/templates/svg/icons/chevron-right.hbs @@ -0,0 +1 @@ + diff --git a/ui/app/templates/svg/icons/chevron-up.hbs b/ui/app/templates/svg/icons/chevron-up.hbs new file mode 100644 index 000000000..7fe3a4767 --- /dev/null +++ b/ui/app/templates/svg/icons/chevron-up.hbs @@ -0,0 +1 @@ + diff --git a/ui/app/templates/svg/icons/clipboard.hbs b/ui/app/templates/svg/icons/clipboard.hbs new file mode 100644 index 000000000..0cf1cce11 --- /dev/null +++ b/ui/app/templates/svg/icons/clipboard.hbs @@ -0,0 +1 @@ + diff --git a/ui/app/templates/svg/icons/close-circled.hbs b/ui/app/templates/svg/icons/close-circled.hbs new file mode 100644 index 000000000..638b01e99 --- /dev/null +++ b/ui/app/templates/svg/icons/close-circled.hbs @@ -0,0 +1 @@ + diff --git a/ui/app/templates/svg/icons/close-round.hbs b/ui/app/templates/svg/icons/close-round.hbs new file mode 100644 index 000000000..03190c424 --- /dev/null +++ b/ui/app/templates/svg/icons/close-round.hbs @@ -0,0 +1 @@ + diff --git a/ui/app/templates/svg/icons/close.hbs b/ui/app/templates/svg/icons/close.hbs new file mode 100644 index 000000000..4c2f2d1a8 --- /dev/null +++ b/ui/app/templates/svg/icons/close.hbs @@ -0,0 +1 @@ + diff --git a/ui/app/templates/svg/icons/cube.hbs b/ui/app/templates/svg/icons/cube.hbs new file mode 100644 index 000000000..eaa45143d --- /dev/null +++ b/ui/app/templates/svg/icons/cube.hbs @@ -0,0 +1 @@ + diff --git a/ui/app/templates/svg/icons/document.hbs b/ui/app/templates/svg/icons/document.hbs new file mode 100644 index 000000000..ac74873a9 --- /dev/null +++ b/ui/app/templates/svg/icons/document.hbs @@ -0,0 +1 @@ + diff --git a/ui/app/templates/svg/icons/false.hbs b/ui/app/templates/svg/icons/false.hbs new file mode 100644 index 000000000..631cc1548 --- /dev/null +++ b/ui/app/templates/svg/icons/false.hbs @@ -0,0 +1,6 @@ + + + + + + diff --git a/ui/app/templates/svg/icons/file.hbs b/ui/app/templates/svg/icons/file.hbs new file mode 100644 index 000000000..0789224dd --- /dev/null +++ b/ui/app/templates/svg/icons/file.hbs @@ -0,0 +1,3 @@ + + + diff --git a/ui/app/templates/svg/icons/folder.hbs b/ui/app/templates/svg/icons/folder.hbs new file mode 100644 index 000000000..d153f9f6a --- /dev/null +++ b/ui/app/templates/svg/icons/folder.hbs @@ -0,0 +1,3 @@ + + + diff --git a/ui/app/templates/svg/icons/grid.hbs b/ui/app/templates/svg/icons/grid.hbs new file mode 100644 index 000000000..dc4943fc6 --- /dev/null +++ b/ui/app/templates/svg/icons/grid.hbs @@ -0,0 +1,2 @@ + + diff --git a/ui/app/templates/svg/icons/hashicorp.hbs b/ui/app/templates/svg/icons/hashicorp.hbs new file mode 100644 index 000000000..0c4599430 --- /dev/null +++ b/ui/app/templates/svg/icons/hashicorp.hbs @@ -0,0 +1 @@ + diff --git a/ui/app/templates/svg/icons/information-circled.hbs b/ui/app/templates/svg/icons/information-circled.hbs new file mode 100644 index 000000000..49a0f159c --- /dev/null +++ b/ui/app/templates/svg/icons/information-circled.hbs @@ -0,0 +1 @@ + diff --git a/ui/app/templates/svg/icons/information-reversed.hbs b/ui/app/templates/svg/icons/information-reversed.hbs new file mode 100644 index 000000000..528d04ecd --- /dev/null +++ b/ui/app/templates/svg/icons/information-reversed.hbs @@ -0,0 +1,6 @@ + + + + + + diff --git a/ui/app/templates/svg/icons/information.hbs b/ui/app/templates/svg/icons/information.hbs new file mode 100644 index 000000000..cd5718d44 --- /dev/null +++ b/ui/app/templates/svg/icons/information.hbs @@ -0,0 +1 @@ + diff --git a/ui/app/templates/svg/icons/ios-search-strong.hbs b/ui/app/templates/svg/icons/ios-search-strong.hbs new file mode 100644 index 000000000..46763f979 --- /dev/null +++ b/ui/app/templates/svg/icons/ios-search-strong.hbs @@ -0,0 +1,2 @@ + + diff --git a/ui/app/templates/svg/icons/ios-search.hbs b/ui/app/templates/svg/icons/ios-search.hbs new file mode 100644 index 000000000..7844b9af7 --- /dev/null +++ b/ui/app/templates/svg/icons/ios-search.hbs @@ -0,0 +1 @@ + diff --git a/ui/app/templates/svg/icons/key.hbs b/ui/app/templates/svg/icons/key.hbs new file mode 100644 index 000000000..aeecbb7ad --- /dev/null +++ b/ui/app/templates/svg/icons/key.hbs @@ -0,0 +1,2 @@ + + diff --git a/ui/app/templates/svg/icons/list.hbs b/ui/app/templates/svg/icons/list.hbs new file mode 100644 index 000000000..a598ebbc9 --- /dev/null +++ b/ui/app/templates/svg/icons/list.hbs @@ -0,0 +1 @@ + diff --git a/ui/app/templates/svg/icons/locked.hbs b/ui/app/templates/svg/icons/locked.hbs new file mode 100644 index 000000000..5bef339e2 --- /dev/null +++ b/ui/app/templates/svg/icons/locked.hbs @@ -0,0 +1 @@ + diff --git a/ui/app/templates/svg/icons/log-in.hbs b/ui/app/templates/svg/icons/log-in.hbs new file mode 100644 index 000000000..ca34c02f8 --- /dev/null +++ b/ui/app/templates/svg/icons/log-in.hbs @@ -0,0 +1 @@ + diff --git a/ui/app/templates/svg/icons/log-out.hbs b/ui/app/templates/svg/icons/log-out.hbs new file mode 100644 index 000000000..3ae858d27 --- /dev/null +++ b/ui/app/templates/svg/icons/log-out.hbs @@ -0,0 +1 @@ + diff --git a/ui/app/templates/svg/icons/loop.hbs b/ui/app/templates/svg/icons/loop.hbs new file mode 100644 index 000000000..c872bd369 --- /dev/null +++ b/ui/app/templates/svg/icons/loop.hbs @@ -0,0 +1 @@ + diff --git a/ui/app/templates/svg/icons/more.hbs b/ui/app/templates/svg/icons/more.hbs new file mode 100644 index 000000000..57d4583cf --- /dev/null +++ b/ui/app/templates/svg/icons/more.hbs @@ -0,0 +1 @@ + diff --git a/ui/app/templates/svg/icons/perf-replication.hbs b/ui/app/templates/svg/icons/perf-replication.hbs new file mode 100644 index 000000000..98e99c567 --- /dev/null +++ b/ui/app/templates/svg/icons/perf-replication.hbs @@ -0,0 +1,6 @@ + + + + + + diff --git a/ui/app/templates/svg/icons/power.hbs b/ui/app/templates/svg/icons/power.hbs new file mode 100644 index 000000000..3b4245297 --- /dev/null +++ b/ui/app/templates/svg/icons/power.hbs @@ -0,0 +1 @@ + diff --git a/ui/app/templates/svg/icons/radio-waves.hbs b/ui/app/templates/svg/icons/radio-waves.hbs new file mode 100644 index 000000000..00a520e44 --- /dev/null +++ b/ui/app/templates/svg/icons/radio-waves.hbs @@ -0,0 +1,2 @@ + + diff --git a/ui/app/templates/svg/icons/replication.hbs b/ui/app/templates/svg/icons/replication.hbs new file mode 100644 index 000000000..4fbc5a850 --- /dev/null +++ b/ui/app/templates/svg/icons/replication.hbs @@ -0,0 +1,2 @@ + + diff --git a/ui/app/templates/svg/icons/reply.hbs b/ui/app/templates/svg/icons/reply.hbs new file mode 100644 index 000000000..9436f85d4 --- /dev/null +++ b/ui/app/templates/svg/icons/reply.hbs @@ -0,0 +1 @@ + diff --git a/ui/app/templates/svg/icons/role.hbs b/ui/app/templates/svg/icons/role.hbs new file mode 100644 index 000000000..e6ee37843 --- /dev/null +++ b/ui/app/templates/svg/icons/role.hbs @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/ui/app/templates/svg/icons/shuffle.hbs b/ui/app/templates/svg/icons/shuffle.hbs new file mode 100644 index 000000000..4a386c30d --- /dev/null +++ b/ui/app/templates/svg/icons/shuffle.hbs @@ -0,0 +1,2 @@ + + diff --git a/ui/app/templates/svg/icons/trash-a.hbs b/ui/app/templates/svg/icons/trash-a.hbs new file mode 100644 index 000000000..89372ac92 --- /dev/null +++ b/ui/app/templates/svg/icons/trash-a.hbs @@ -0,0 +1 @@ + diff --git a/ui/app/templates/svg/icons/true.hbs b/ui/app/templates/svg/icons/true.hbs new file mode 100644 index 000000000..43469dbfa --- /dev/null +++ b/ui/app/templates/svg/icons/true.hbs @@ -0,0 +1,6 @@ + + + + + + diff --git a/ui/app/templates/svg/icons/unlocked.hbs b/ui/app/templates/svg/icons/unlocked.hbs new file mode 100644 index 000000000..01cc126bb --- /dev/null +++ b/ui/app/templates/svg/icons/unlocked.hbs @@ -0,0 +1 @@ + diff --git a/ui/app/templates/svg/icons/vault.hbs b/ui/app/templates/svg/icons/vault.hbs new file mode 100644 index 000000000..5e5f220dc --- /dev/null +++ b/ui/app/templates/svg/icons/vault.hbs @@ -0,0 +1 @@ + diff --git a/ui/app/templates/svg/icons/wand.hbs b/ui/app/templates/svg/icons/wand.hbs new file mode 100644 index 000000000..2c9a8ea5f --- /dev/null +++ b/ui/app/templates/svg/icons/wand.hbs @@ -0,0 +1 @@ + diff --git a/ui/app/templates/svg/initialize.hbs b/ui/app/templates/svg/initialize.hbs new file mode 100644 index 000000000..d8cc987b6 --- /dev/null +++ b/ui/app/templates/svg/initialize.hbs @@ -0,0 +1,963 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui/app/templates/svg/vault-edition-logo.hbs b/ui/app/templates/svg/vault-edition-logo.hbs new file mode 100644 index 000000000..2e96a4818 --- /dev/null +++ b/ui/app/templates/svg/vault-edition-logo.hbs @@ -0,0 +1,22 @@ + + + + + + + + {{#if (is-version "Enterprise")}} + + + + + + + + + + + + + {{/if}} + diff --git a/ui/app/templates/svg/vault-enterprise.hbs b/ui/app/templates/svg/vault-enterprise.hbs new file mode 100644 index 000000000..8ee0442f3 --- /dev/null +++ b/ui/app/templates/svg/vault-enterprise.hbs @@ -0,0 +1,8 @@ + + + + + diff --git a/ui/app/templates/svg/vault-logo.hbs b/ui/app/templates/svg/vault-logo.hbs new file mode 100644 index 000000000..efc2811f0 --- /dev/null +++ b/ui/app/templates/svg/vault-logo.hbs @@ -0,0 +1,3 @@ + + + diff --git a/ui/app/templates/vault/cluster.hbs b/ui/app/templates/vault/cluster.hbs new file mode 100644 index 000000000..c24cd6895 --- /dev/null +++ b/ui/app/templates/vault/cluster.hbs @@ -0,0 +1 @@ +{{outlet}} diff --git a/ui/app/templates/vault/cluster/access.hbs b/ui/app/templates/vault/cluster/access.hbs new file mode 100644 index 000000000..be96cf4f8 --- /dev/null +++ b/ui/app/templates/vault/cluster/access.hbs @@ -0,0 +1,27 @@ +
+ {{#menu-sidebar title="Access" class="is-3" data-test-sidebar=true}} +
  • + {{#link-to "vault.cluster.access.methods" data-test-link=true current-when="vault.cluster.access.methods vault.cluster.access.method"}} + Auth Methods + {{/link-to}} +
  • +
  • + {{#link-to "vault.cluster.access.identity" "entities" data-test-link=true }} + Entities + {{/link-to}} +
  • +
  • + {{#link-to "vault.cluster.access.identity" "groups" data-test-link=true }} + Groups + {{/link-to}} +
  • +
  • + {{#link-to "vault.cluster.access.leases" data-test-link=true}} + Leases + {{/link-to}} +
  • + {{/menu-sidebar}} +
    + {{outlet}} +
    +
    diff --git a/ui/app/templates/vault/cluster/access/identity/aliases/add.hbs b/ui/app/templates/vault/cluster/access/identity/aliases/add.hbs new file mode 100644 index 000000000..5b41e4cfa --- /dev/null +++ b/ui/app/templates/vault/cluster/access/identity/aliases/add.hbs @@ -0,0 +1,11 @@ + + +{{identity/edit-form model=model onSave=(perform navToShow)}} diff --git a/ui/app/templates/vault/cluster/access/identity/aliases/edit.hbs b/ui/app/templates/vault/cluster/access/identity/aliases/edit.hbs new file mode 100644 index 000000000..a13fc9e44 --- /dev/null +++ b/ui/app/templates/vault/cluster/access/identity/aliases/edit.hbs @@ -0,0 +1,11 @@ + + +{{identity/edit-form mode="edit" model=model onSave=(perform navToShow)}} diff --git a/ui/app/templates/vault/cluster/access/identity/aliases/index.hbs b/ui/app/templates/vault/cluster/access/identity/aliases/index.hbs new file mode 100644 index 000000000..7d715af32 --- /dev/null +++ b/ui/app/templates/vault/cluster/access/identity/aliases/index.hbs @@ -0,0 +1,41 @@ +{{identity/entity-nav identityType=identityType}} +{{#if model.meta.total}} + {{#each model as |item|}} + + {{i-con + glyph="role" + size=14 + class="has-text-grey-light" + }} + + {{item.id}} + + + {{/each}} +{{else}} +
    +
    +
    +
    +

    + There are currently no {{identityType}} aliases. +

    +
    +
    +
    +
    +{{/if}} +{{#if (gt model.meta.lastPage 1) }} + {{list-pagination + page=model.meta.currentPage + lastPage=model.meta.lastPage + link="vault.cluster.access.identity.aliases.index" + }} +{{/if}} diff --git a/ui/app/templates/vault/cluster/access/identity/aliases/show.hbs b/ui/app/templates/vault/cluster/access/identity/aliases/show.hbs new file mode 100644 index 000000000..24f7d9cdf --- /dev/null +++ b/ui/app/templates/vault/cluster/access/identity/aliases/show.hbs @@ -0,0 +1,39 @@ + +
    + +
    +{{component (concat 'identity/item-alias/alias-' section) model=model}} diff --git a/ui/app/templates/vault/cluster/access/identity/create.hbs b/ui/app/templates/vault/cluster/access/identity/create.hbs new file mode 100644 index 000000000..3fce9e8d3 --- /dev/null +++ b/ui/app/templates/vault/cluster/access/identity/create.hbs @@ -0,0 +1,11 @@ + + +{{identity/edit-form model=model onSave=(perform navToShow)}} diff --git a/ui/app/templates/vault/cluster/access/identity/edit.hbs b/ui/app/templates/vault/cluster/access/identity/edit.hbs new file mode 100644 index 000000000..a13fc9e44 --- /dev/null +++ b/ui/app/templates/vault/cluster/access/identity/edit.hbs @@ -0,0 +1,11 @@ + + +{{identity/edit-form mode="edit" model=model onSave=(perform navToShow)}} diff --git a/ui/app/templates/vault/cluster/access/identity/index.hbs b/ui/app/templates/vault/cluster/access/identity/index.hbs new file mode 100644 index 000000000..c38c5b860 --- /dev/null +++ b/ui/app/templates/vault/cluster/access/identity/index.hbs @@ -0,0 +1,41 @@ +{{identity/entity-nav identityType=identityType}} +{{#if model.meta.total}} + {{#each model as |item|}} + + {{i-con + glyph="role" + size=14 + class="has-text-grey-light" + }} + + {{item.id}} + + + {{/each}} +{{else}} +
    +
    +
    +
    +

    + There are currently no {{pluralize identityType}}. +

    +
    +
    +
    +
    +{{/if}} +{{#if (gt model.meta.lastPage 1) }} + {{list-pagination + page=model.meta.currentPage + lastPage=model.meta.lastPage + link="vault.cluster.access.identity.index" + }} +{{/if}} diff --git a/ui/app/templates/vault/cluster/access/identity/merge.hbs b/ui/app/templates/vault/cluster/access/identity/merge.hbs new file mode 100644 index 000000000..064692ce9 --- /dev/null +++ b/ui/app/templates/vault/cluster/access/identity/merge.hbs @@ -0,0 +1,11 @@ + + +{{identity/edit-form mode="merge" model=model onSave=(perform navToShow)}} diff --git a/ui/app/templates/vault/cluster/access/identity/show.hbs b/ui/app/templates/vault/cluster/access/identity/show.hbs new file mode 100644 index 000000000..41afa9796 --- /dev/null +++ b/ui/app/templates/vault/cluster/access/identity/show.hbs @@ -0,0 +1,45 @@ + +
    + +
    +{{component (concat 'identity/item-' section) model=model}} diff --git a/ui/app/templates/vault/cluster/access/leases/error.hbs b/ui/app/templates/vault/cluster/access/leases/error.hbs new file mode 100644 index 000000000..24ebae834 --- /dev/null +++ b/ui/app/templates/vault/cluster/access/leases/error.hbs @@ -0,0 +1,49 @@ + + +{{#unless (or (eq model.httpStatus 400) (eq model.httpStatus 404))}} + {{model.message}} +{{/unless}} + +
    +
    +
    +
    + {{#if (eq model.httpStatus 400)}} +

    + {{i-con glyph='close'}} + {{model.keyId}} is not a valid lease ID +

    + {{else if (eq model.httpStatus 404)}} +

    + Unable to find lease for the id: {{model.keyId}}. Try going back to the + {{#link-to "vault.cluster.access.leases"}}lookup{{/link-to}} + and re-entering the id. +

    + {{else if (eq model.httpStatus 403)}} +

    + You don't have access to {{model.keyId}}. If you think you've reached this page in error, please contact your administrator. +

    + {{else}} + {{#each model.errors as |error|}} +

    + {{error}} +

    + {{/each}} + {{/if}} +
    +
    +
    +
    +
    + {{#link-to "vault.cluster.access.leases" class="button"}} + Back + {{/link-to}} +
    diff --git a/ui/app/templates/vault/cluster/access/leases/index.hbs b/ui/app/templates/vault/cluster/access/leases/index.hbs new file mode 100644 index 000000000..5ffc30202 --- /dev/null +++ b/ui/app/templates/vault/cluster/access/leases/index.hbs @@ -0,0 +1,28 @@ + + +
    +
    +
    + +
    + {{input value=leaseId id="lease-id" class="input"}} +
    +

    + If you know the id of a lease, enter it above to lookup details of the lease. +

    +
    +
    +
    +
    + +
    +
    +
    diff --git a/ui/app/templates/vault/cluster/access/leases/list.hbs b/ui/app/templates/vault/cluster/access/leases/list.hbs new file mode 100644 index 000000000..526ec305e --- /dev/null +++ b/ui/app/templates/vault/cluster/access/leases/list.hbs @@ -0,0 +1,149 @@ + +
    +
    +
    + {{navigate-input + filterFocusDidChange=(action "setFilterFocus") + filterDidChange=(action "setFilter") + filter=filter + filterMatchesKey=filterMatchesKey + firstPartialMatch=firstPartialMatch + baseKey=(get baseKey "id") + shouldNavigateTree=true + mode='leases' + placeholder='Filter leases' + }} + {{#if filterFocused}} +   +   + {{#if filterMatchesKey}} + {{#unless filterIsFolder}} +

    + ENTER to go to see details +

    + {{/unless}} + {{/if}} + {{#if firstPartialMatch}} +

    + TAB to complete +

    + {{/if}} + {{/if}} +
    +
    +
    + +{{#if model.meta.total}} + {{#each model as |item|}} + + {{i-con + glyph=(if item.isFolder 'folder' 'document') + size=14 + class="has-text-grey-light" + }} + + {{or item.keyWithoutParent item.id}} + + + {{else}} +
    + There are no leases matching {{filter}} +
    + {{/each}} +{{else}} +
    +
    +
    +
    +

    + {{#if (eq baseKey.id '')}} + There are currently no leases. + {{else}} + {{#if filterIsFolder}} + {{#if (eq filter baseKey.id)}} + There are no leases under {{filter}}. + {{else}} + We couldn't find a prefix matching {{filter}}. + {{/if}} + {{/if}} + {{/if}} +

    +
    +
    +
    +
    +{{/if}} +{{#if (gt model.meta.lastPage 1) }} + {{list-pagination + page=model.meta.currentPage + lastPage=model.meta.lastPage + link=(concat "vault.cluster.access.leases.list" (if (not baseKey.id) "-root")) + model=(compact (array (if baseKey.id baseKey.id))) + }} +{{/if}} diff --git a/ui/app/templates/vault/cluster/access/leases/show.hbs b/ui/app/templates/vault/cluster/access/leases/show.hbs new file mode 100644 index 000000000..5e4307d6a --- /dev/null +++ b/ui/app/templates/vault/cluster/access/leases/show.hbs @@ -0,0 +1,83 @@ + +
    + {{#info-table-row label="Issue time" value=model.issueTime}} + {{moment-format model.issueTime 'MMM DD, YYYY hh:mm:ss A'}} +
    + + {{model.issueTime}} + + {{/info-table-row}} + {{info-table-row label="Renewable" value=model.renewable}} + {{#info-table-row label="Last renewal" value=model.lastRenewal}} + {{moment-format model.lastRenewal 'MMM DD, YYYY hh:mm:ss A'}} +
    + + {{model.lastRenewal}} + + {{/info-table-row}} + {{#if model.expireTime}} + {{#info-table-row label="Expiration time" value=model.expireTime}} + {{moment-format model.expireTime 'MMM DD, YYYY hh:mm:ss A'}} +
    + + {{model.expireTime}} + + {{/info-table-row}} + {{info-table-row label="Expires in" value=(moment-from-now model.expireTime interval=1000 hideSuffix=true)}} + {{/if}} + {{info-table-row label="TTL" value=model.ttl}} +
    +{{#if (and (not model.isAuthLease) model.renewable capabilities.renew.canUpdate)}} +
    +

    Renew Lease

    +
    + {{ttl-picker + labelText="Interval" + labelClass="is-label" + onChange=(action (mut interval)) + outputSeconds=true + }} +
    +
    + +
    +
    +
    +
    +{{/if}} +
    +
    {{!-- needs to be here to push over the button --}}
    + {{#if capabilities.revoke.canUpdate}} + {{#confirm-action + onConfirmAction=(action "revokeLease" model) + confirmMessage= (concat "Are you sure you want to revoke this lease?") + confirmButtonText="Confirm revocation" + cancelButtonText="Cancel" + data-test-lease-revoke=true + }} + Revoke lease + {{/confirm-action}} + {{/if}} +
    diff --git a/ui/app/templates/vault/cluster/access/method/section.hbs b/ui/app/templates/vault/cluster/access/method/section.hbs new file mode 100644 index 000000000..2419984e7 --- /dev/null +++ b/ui/app/templates/vault/cluster/access/method/section.hbs @@ -0,0 +1,36 @@ + + +{{section-tabs model 'authShow'}} +{{component (concat "auth-method/" section) model=model}} + diff --git a/ui/app/templates/vault/cluster/access/methods.hbs b/ui/app/templates/vault/cluster/access/methods.hbs new file mode 100644 index 000000000..7212c3893 --- /dev/null +++ b/ui/app/templates/vault/cluster/access/methods.hbs @@ -0,0 +1,84 @@ + + +{{#each (sort-by "path" model) as |method|}} + {{#linked-block + "vault.cluster.access.methods" + class="box is-sideless is-marginless has-pointer " + data-test-auth-backend-link=method.id + }} +
    +
    +
    + {{i-con glyph="folder" size=14 class="has-text-grey-light"}} {{method.path}} +
    + + + {{#if (eq method.type 'plugin')}} + {{method.type}}: {{method.config.plugin_name}} + {{else}} + {{method.type}} + {{/if}} + + + + {{method.accessor}} + +
    +
    +
    +
    + {{#popup-menu name="auth-backend-nav" contentClass="is-wide"}} + + {{/popup-menu}} +
    +
    +
    + {{/linked-block}} +{{/each}} diff --git a/ui/app/templates/vault/cluster/auth.hbs b/ui/app/templates/vault/cluster/auth.hbs new file mode 100644 index 000000000..78aab10dd --- /dev/null +++ b/ui/app/templates/vault/cluster/auth.hbs @@ -0,0 +1,30 @@ +{{#splash-page as |s|}} + {{#s.header}} +
    + Sign in to Vault +
    + {{/s.header}} + {{#s.content}} +
    +
    +
    +
    + {{i-con glyph="unlocked" size=20}} {{capitalize model.name}} is {{if model.unsealed 'unsealed' 'sealed'}} +
    +
    +
    +
    + {{auth-form + cluster=model + redirectTo=redirectTo + selectedAuthType=with + }} + {{/s.content}} + {{#s.footer}} +
    +

    + Contact your administrator for login credentials +

    +
    + {{/s.footer}} +{{/splash-page}} diff --git a/ui/app/templates/vault/cluster/error.hbs b/ui/app/templates/vault/cluster/error.hbs new file mode 100644 index 000000000..6bc05baf3 --- /dev/null +++ b/ui/app/templates/vault/cluster/error.hbs @@ -0,0 +1,51 @@ +{{#if (eq model.httpStatus 404)}} + {{not-found model=model}} +{{else}} + +
    + {{#if (and + (eq model.httpStatus 403) + (eq model.policyPath 'sys/capabilities-self') + ) + }} +

    + Your auth token does not have access to {{model.policyPath}}. Vault Enterprise uses this endpoint to determine what actions are allowed in the interface. +

    +

    + Make sure the policy for the path {{model.policyPath}} includes capabilities = ['update']. +

    + {{else if (and + (eq model.httpStatus 403) + (eq model.policyPath 'sys/mounts') + ) + }} +

    + Your auth token does not have access to {{model.policyPath}}. This is necessary in order to browse secret backends. +

    +

    + Make sure the policy for the path {{model.policyPath}} has capabilities = ['list', 'read']. +

    + {{else}} + {{#if model.message}} +

    {{model.message}}

    + {{/if}} + {{#each model.errors as |error|}} +

    + {{error}} +

    + {{/each}} + {{/if}} +
    +{{/if}} diff --git a/ui/app/templates/vault/cluster/init.hbs b/ui/app/templates/vault/cluster/init.hbs new file mode 100644 index 000000000..1eb347824 --- /dev/null +++ b/ui/app/templates/vault/cluster/init.hbs @@ -0,0 +1,173 @@ +{{#splash-page as |s|}} + {{#s.header}} +
    + Connect to Vault and initialize +
    + {{/s.header}} + {{#s.content}} + {{#if keyData}} +
    +
    +
    +

    + Vault has been initialized! +

    +
    +
    +
    + {{partial "svg/initialize"}} +
    +
    +
    +
    +
    +

    + Initial Root Token +

    + {{keyData.root_token}} +
    +
    +
    +

    + Please securely distribute the keys below. When the Vault is re-sealed, restarted, or stopped, you must provide at least {{secret_threshold}} of these keys to unseal it again. + Vault does not store the master key. Without at least {{secret_threshold}} keys, your Vault will remain permanently sealed. +

    +
    + {{#each (if keyData.keys_base64 keyData.keys_base64 keyData.keys) as |key index| }} +
    +
    +

    + Key {{add index 1}} +

    + {{key}} +
    +
    + {{/each}} +
    +
    +
    + {{#if model.sealed}} +
    + {{#link-to 'vault.cluster.unseal' model.name class="button is-primary"}} + Continue to Unseal + {{/link-to}} +
    + {{else}} +
    + {{#link-to 'vault.cluster.auth' model.name class="button is-primary"}} + Continue to Authenticate + {{/link-to}} +
    + {{/if}} + {{download-button + actionText="Download Keys" + data=keyData + filename=keyFilename + mime="application/json" + extension="json" + class="button" + stringify=true + }} +
    +
    + {{else}} +
    +
    +
    +
    +

    + Let’s set up the initial set of master keys and the backend data store structure +

    +
    +
    +
    + {{partial "svg/initialize"}} +
    +
    +
    + {{message-error errors=errors}} +
    +
    +
    + +
    + {{input class="input" name="key-shares" type="number" step="1" min="1" pattern="[0-9]*" value=secret_shares}} +
    +

    + The number of key shares to split the master key into +

    +
    +
    +
    +
    + +
    + {{input class="input" name="key-threshold" type="number" step="1" min="1" pattern="[0-9]*" value=secret_threshold}} +
    +

    + The number of key shares required to reconstruct the master key +

    +
    +
    +
    + + {{toggle-button + openLabel="Encrypt Output with PGP" + closedLabel="Encrypt Output with PGP" + toggleTarget=this + toggleAttr="use_pgp" + class="is-block" + }} + {{#if use_pgp}} +
    +

    + The output unseal keys will be encrypted and hex-encoded, in order, with the given public keys. +

    + {{pgp-list listLength=secret_shares onDataUpdate=(action 'setKeys')}} +
    + {{/if}} + {{toggle-button + openLabel="Encrypt Root Token with PGP" + closedLabel="Encrypt Root Token with PGP" + toggleTarget=this + toggleAttr="use_pgp_for_root" + class="is-block" + }} + {{#if use_pgp_for_root}} +
    +

    + The root unseal key will be encrypted and hex-encoded with the given public key. +

    + {{pgp-list listLength=1 onDataUpdate=(action 'setRootKey')}} +
    + {{/if}} +
    +
    +
    + +
    + {{/if}} + {{/s.content}} +{{/splash-page}} diff --git a/ui/app/templates/vault/cluster/loading.hbs b/ui/app/templates/vault/cluster/loading.hbs new file mode 100644 index 000000000..b8b5ad854 --- /dev/null +++ b/ui/app/templates/vault/cluster/loading.hbs @@ -0,0 +1 @@ +{{partial "partials/loading"}} diff --git a/ui/app/templates/vault/cluster/not-found.hbs b/ui/app/templates/vault/cluster/not-found.hbs new file mode 100644 index 000000000..3ce01ab82 --- /dev/null +++ b/ui/app/templates/vault/cluster/not-found.hbs @@ -0,0 +1 @@ +{{not-found model=model}} diff --git a/ui/app/templates/vault/cluster/policies.hbs b/ui/app/templates/vault/cluster/policies.hbs new file mode 100644 index 000000000..c3f94acce --- /dev/null +++ b/ui/app/templates/vault/cluster/policies.hbs @@ -0,0 +1,38 @@ + diff --git a/ui/app/templates/vault/cluster/policies/create.hbs b/ui/app/templates/vault/cluster/policies/create.hbs new file mode 100644 index 000000000..cc79d6f6d --- /dev/null +++ b/ui/app/templates/vault/cluster/policies/create.hbs @@ -0,0 +1,110 @@ + + +
    +
    + {{message-error model=model}} +
    + +
    + +
    +
    +
    +
    +
    + +
    +
    +
    +
    + {{input + id="fileUploadToggle" + type="checkbox" + name="fileUploadToggle" + class="switch is-rounded is-success is-small" + checked=showFileUpload + change=(toggle-action "showFileUpload" this) + data-test-policy-edit-toggle=true + }} + +
    +
    +
    +
    + {{#if showFileUpload}} + {{text-file + inputOnly=true + index="" + file=file + onChange=(action "setPolicyFromFile") + }} + {{else}} + {{ivy-codemirror + value=model.policy + id="policy" + valueUpdated=(action (mut model.policy)) + options=(hash + lineNumbers=true + tabSize=2 + mode='ruby' + theme='hashi' + extraKeys=(hash + Shift-Enter=(action "savePolicy" model) + ) + ) + }} +
    +

    + You can use Alt+Tab (Option+Tab on MacOS) in the code editor to skip to the next field +

    +
    + {{/if}} +
    + {{#each model.additionalAttrs as |attr|}} + {{form-field data-test-field attr=attr model=model}} + {{/each}} +
    +
    +
    + +
    +
    + {{#link-to + "vault.cluster.policies" + replace=true + class="button" + }} + Cancel + {{/link-to}} +
    +
    +
    diff --git a/ui/app/templates/vault/cluster/policies/index.hbs b/ui/app/templates/vault/cluster/policies/index.hbs new file mode 100644 index 000000000..14f86b934 --- /dev/null +++ b/ui/app/templates/vault/cluster/policies/index.hbs @@ -0,0 +1,171 @@ +{{#if (or (eq policyType "acl") (has-feature "Sentinel"))}} + +
    +
    +
    + {{navigate-input + filterFocusDidChange=(action "setFilterFocus") + filterDidChange=(action "setFilter") + filter=filter + filterMatchesKey=filterMatchesKey + firstPartialMatch=firstPartialMatch + extraNavParams=policyType + placeholder="Filter policies" + mode="policy" + }} + {{#if filterFocused}} + {{#if filterMatchesKey}} +

    + ENTER to go to {{or pageFilter filter}} +

    + {{/if}} + {{#if firstPartialMatch}} +

    + TAB to complete {{firstPartialMatch.id}} +

    + {{/if}} + {{/if}} +
    +
    +
    + {{#if model.meta.total}} + {{#each model as |item|}} + {{#if (eq item.id "root")}} +
    + {{i-con + glyph='file' + size=14 + class="has-text-grey-light" + }} +
    + {{item.id}} +

    + The root policy does not contain any rules but can do anything within Vault. It should be used with extreme care. +

    +
    +
    + {{else}} + {{#linked-block + "vault.cluster.policy.show" + policyType + item.id + class="box is-sideless is-marginless" + data-test-policy-link=item.id + }} +
    + +
    + {{#popup-menu name="policy-nav"}} + + {{/popup-menu}} +
    +
    + {{/linked-block}} + {{/if}} + {{else}} +
    +

    There are no policies matching {{pageFilter}}.

    +
    + {{/each}} + {{else}} +
    +
    +
    +
    +

    + There are currently no {{uppercase policyType}} policies. +

    +
    +
    +
    +
    + {{/if}} + {{#if (gt model.meta.lastPage 1) }} + {{list-pagination + page=model.meta.currentPage + lastPage=model.meta.lastPage + link="vault.cluster.policies.index" + }} + {{/if}} +{{else}} + {{upgrade-page title="Sentinel" minimumEdition="Vault Enterprise Premium"}} +{{/if}} diff --git a/ui/app/templates/vault/cluster/policies/loading.hbs b/ui/app/templates/vault/cluster/policies/loading.hbs new file mode 100644 index 000000000..b8b5ad854 --- /dev/null +++ b/ui/app/templates/vault/cluster/policies/loading.hbs @@ -0,0 +1 @@ +{{partial "partials/loading"}} diff --git a/ui/app/templates/vault/cluster/policy.hbs b/ui/app/templates/vault/cluster/policy.hbs new file mode 100644 index 000000000..7351a1dcd --- /dev/null +++ b/ui/app/templates/vault/cluster/policy.hbs @@ -0,0 +1,36 @@ + diff --git a/ui/app/templates/vault/cluster/policy/edit.hbs b/ui/app/templates/vault/cluster/policy/edit.hbs new file mode 100644 index 000000000..850bcb8f3 --- /dev/null +++ b/ui/app/templates/vault/cluster/policy/edit.hbs @@ -0,0 +1,105 @@ + + +
    +
    +
    +
    + +
    +
    +
    + {{#if (and (not-eq model.id "root") (or capabilities.canUpdate capabilities.canDelete))}} +
    + {{input + id="edit" + type="checkbox" + name="navToEdit" + class="switch is-rounded is-success is-small" + checked=true + change=(action (nav-to-route 'vault.cluster.policy.show' model.id replace=true) ) + data-test-policy-edit-toggle=true + }} + +
    + {{/if}} +
    +
    +
    + {{message-error model=model}} +
    + {{ivy-codemirror + value=model.policy + valueUpdated=(action (mut model.policy)) + options=(hash + lineNumbers=true + tabSize=2 + mode='ruby' + theme='hashi' + extraKeys=(hash + Shift-Enter=(action "savePolicy" model) + ) + ) + }} +
    +

    + You can use Alt+Tab (Option+Tab on MacOS) in the code editor to skip to the next field +

    +
    +
    + {{#each model.additionalAttrs as |attr|}} + {{form-field data-test-field attr=attr model=model}} + {{/each}} +
    +
    + {{#if capabilities.canUpdate}} +
    + +
    + {{/if}} + +
    + {{#if (and (not-eq model.id "default") capabilities.canDelete)}} + {{#confirm-action + buttonClasses="button is-link is-outlined is-inverted" + onConfirmAction=(action "deletePolicy" model) + confirmMessage=(concat "Are you sure you want to delete " model.id "?") + data-test-policy-delete=true + }} + Delete + {{/confirm-action}} + {{/if}} +
    +
    +
    diff --git a/ui/app/templates/vault/cluster/policy/error.hbs b/ui/app/templates/vault/cluster/policy/error.hbs new file mode 100644 index 000000000..dc1618250 --- /dev/null +++ b/ui/app/templates/vault/cluster/policy/error.hbs @@ -0,0 +1,25 @@ + {{#if (eq model.httpStatus 404)}} + {{not-found model=model}} + {{else}} + +
    + {{#if model.message}} +

    {{model.message}}

    + {{/if}} + {{#each model.errors as |error|}} +

    {{error}}

    + {{/each}} +
    + {{/if}} diff --git a/ui/app/templates/vault/cluster/policy/loading.hbs b/ui/app/templates/vault/cluster/policy/loading.hbs new file mode 100644 index 000000000..b8b5ad854 --- /dev/null +++ b/ui/app/templates/vault/cluster/policy/loading.hbs @@ -0,0 +1 @@ +{{partial "partials/loading"}} diff --git a/ui/app/templates/vault/cluster/policy/show.hbs b/ui/app/templates/vault/cluster/policy/show.hbs new file mode 100644 index 000000000..593336270 --- /dev/null +++ b/ui/app/templates/vault/cluster/policy/show.hbs @@ -0,0 +1,84 @@ + +
    +
    +
    + + {{#if (eq policyType "acl")}} + ({{uppercase model.format}} format) + {{/if}} +
    +
    +
    + {{#if (and (not-eq model.id "root") (or capabilities.canUpdate capabilities.canDelete))}} +
    + {{input + id="edit" + type="checkbox" + name="navToEdit" + class="switch is-rounded is-success is-small" + checked=false + change=(action (nav-to-route 'vault.cluster.policy.edit' model.id replace=true) ) + data-test-policy-edit-toggle=true + }} + +
    + {{/if}} +
    +
    +
    +
    + {{ivy-codemirror + value=model.policy + options=(hash + readOnly=true + lineNumbers=true + tabSize=2 + mode='ruby' + theme='hashi' + ) + }} +
    + {{#if model.paths}} +
    +

    Paths

    +
      + {{#each model.paths as |path|}} +
    • + {{path}} +
    • + {{/each}} +
    +
    + {{/if}} +
    + {{download-button + classNames="link is-pulled-right" + actionText="Download policy" + extension=(if (eq policyType "acl") model.format "sentinel") + filename=model.name + data=model.policy + }} +
    +
    + diff --git a/ui/app/templates/vault/cluster/replication-dr-promote.hbs b/ui/app/templates/vault/cluster/replication-dr-promote.hbs new file mode 100644 index 000000000..e5e7463f7 --- /dev/null +++ b/ui/app/templates/vault/cluster/replication-dr-promote.hbs @@ -0,0 +1,63 @@ +{{#splash-page as |s|}} + {{#s.header}} +
    + Disaster Recovery Secondary is enabled +
    + {{/s.header}} + {{#s.content}} + + {{#if (eq action 'promote')}} +
    +
    +
    +
    + {{i-con glyph="alert-circled" size=28 excludeIconClass=true}} +
    +
    +

    + + Caution: Vault replication is not designed for active-active usage and enabling two performance primaries should never be done, as it can lead to data loss if they or their secondaries are ever reconnected. + +

    +
    +
    +
    +
    + {{replication-actions replicationMode="dr" selectedAction="promote" model=model}} + {{/if}} + {{#if (eq action 'update')}} + {{replication-actions replicationMode="dr" selectedAction="updatePrimary" model=model}} + {{/if}} + {{#unless action}} + {{#shamir-flow + action="generate-dr-operation-token" + buttonText="Promote cluster" + fetchOnInit=true + generateAction=true + }} +

    + Generate an Operation Token by entering a portion of the master key. + Once all portions are entered, the generated operation token may be used to manage your Seondary Disaster Recovery cluster. +

    + {{/shamir-flow}} + {{/unless}} + {{/s.content}} +{{/splash-page}} diff --git a/ui/app/templates/vault/cluster/replication/index.hbs b/ui/app/templates/vault/cluster/replication/index.hbs new file mode 100644 index 000000000..6540915dc --- /dev/null +++ b/ui/app/templates/vault/cluster/replication/index.hbs @@ -0,0 +1,21 @@ +{{#if (eq model.mode 'unsupported')}} + +
    +

    + The current cluster configuration does not support replication. +

    +
    +{{else}} + {{replication-summary + cluster=model + showModeSummary=true + }} +{{/if}} diff --git a/ui/app/templates/vault/cluster/replication/mode.hbs b/ui/app/templates/vault/cluster/replication/mode.hbs new file mode 100644 index 000000000..58dbd43fe --- /dev/null +++ b/ui/app/templates/vault/cluster/replication/mode.hbs @@ -0,0 +1,55 @@ +{{#if model.replicationAttrs.replicationEnabled}} + + +
    + +
    +{{/if}} +{{outlet}} diff --git a/ui/app/templates/vault/cluster/replication/mode/index.hbs b/ui/app/templates/vault/cluster/replication/mode/index.hbs new file mode 100644 index 000000000..54f898ee7 --- /dev/null +++ b/ui/app/templates/vault/cluster/replication/mode/index.hbs @@ -0,0 +1,4 @@ +{{replication-summary + cluster=model + initialReplicationMode=replicationMode +}} diff --git a/ui/app/templates/vault/cluster/replication/mode/manage.hbs b/ui/app/templates/vault/cluster/replication/mode/manage.hbs new file mode 100644 index 000000000..17fb025b9 --- /dev/null +++ b/ui/app/templates/vault/cluster/replication/mode/manage.hbs @@ -0,0 +1,11 @@ +{{#each (replication-action-for-mode replicationMode model.replicationAttrs.modeForUrl) as |replicationAction index|}} + {{#if (get model (concat 'can' (camelize replicationAction)))}} +
    + {{replication-actions + replicationMode=replicationMode + model=model + selectedAction=replicationAction + }} +
    + {{/if}} +{{/each}} diff --git a/ui/app/templates/vault/cluster/replication/mode/secondaries/add.hbs b/ui/app/templates/vault/cluster/replication/mode/secondaries/add.hbs new file mode 100644 index 000000000..86e921521 --- /dev/null +++ b/ui/app/templates/vault/cluster/replication/mode/secondaries/add.hbs @@ -0,0 +1,90 @@ +
    +
    +

    + Generate a secondary token +

    +

    Generate a token to enable {{replicationMode}} replication or change primaries on secondary cluster.

    +
    + {{message-error errors=errors}} + {{#if token}} +
    + +
    +