UI namespaces (#5119)

* add namespace sidebar item

* depend on ember-inflector directly

* list-view and list-item components

* fill out components and render empty namespaces page

* list namespaces in access

* add menu contextual component to list item

* popup contextual component

* full crud for namespaces

* add namespaces service and picker component

* split application and vault.cluster templates and controllers, add namespace query param, add namespace-picker to vault.namespace template

* remove usage of href-to

* remove ember-href-to from deps

* add ember-responsive

* start styling the picker and link to appropriate namespaces, use ember-responsive to render picker in different places based on the breakpoint

* get query param working and save ns to authdata when authenticating, feed through ns in application adapter

* move to observer on the controller for setting state on the service

* set state in the beforeModel hook and clear the ember data model cache

* nav to secrets on change and make error handling more resilient utilizing the method that atlas does to eagerly update URLs

* add a list of sys endpoints in a helper

* hide header elements if not in the root namespace

* debounce namespace input on auth, fix 404 for auth method fetch, move auth method fetch to a task on the auth-form component and refretch on namespace change

* fix display of supported engines and exclusion of sys and identity engines

* don't fetch replication status if you're in a non-root namespace

* hide seal sub-menu if not in the root namespace

* don't autocomplete auth form inputs

* always send some requests to the root namespace

* use methodType and engineType instead of type in case there it is ns_ prefixed

* use sys/internal/ui/namespaces to fetch the list in the dropdown

* don't use model for namespace picker and always make the request to the token namespace

* fix header handling for fetch calls

* use namespace-reminder component on creation and edit forms throughout the application

* add namespace-reminder to the console

* add flat

* add deepmerge for creating the tree in the menu

* delayed rendering for animation timing

* design and code feedback on the first round

* white text in the namespace picker

* fix namespace picker issues with root keys

* separate path-to-tree

* add tests for path-to-tree util

* hide picker if you're in the root ns and you can't access other namespaces

* show error message if you enter invalid characters for namespace path

* return a different model if we dont have the namespaces feature and show upgrade page

* if a token has a namespace_path, use that as the root user namespace and transition them there on login

* use token namespace for user, but use specified namespace to log in

* always renew tokens in the token namespace

* fix edition-badge test
This commit is contained in:
Matthew Irish 2018-08-16 12:48:24 -05:00 committed by GitHub
parent 97ada3fe0b
commit 21af204683
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
169 changed files with 2013 additions and 719 deletions

View File

@ -1,12 +1,15 @@
import Ember from 'ember';
import DS from 'ember-data';
import fetch from 'fetch';
import config from '../config/environment';
const POLLING_URL_PATTERNS = ['sys/seal-status', 'sys/health', 'sys/replication/status'];
const { APP } = config;
const { POLLING_URLS, NAMESPACE_ROOT_URLS } = APP;
const { inject, assign, set, RSVP } = Ember;
export default DS.RESTAdapter.extend({
auth: inject.service(),
namespaceService: inject.service('namespace'),
controlGroup: inject.service(),
flashMessages: inject.service(),
@ -25,17 +28,26 @@ export default DS.RESTAdapter.extend({
return false;
},
_preRequest(url, options) {
const token = options.clientToken || this.get('auth.currentToken');
addHeaders(url, options) {
let token = options.clientToken || this.get('auth.currentToken');
let headers = {};
if (token && !options.unauthenticated) {
options.headers = assign(options.headers || {}, {
'X-Vault-Token': token,
});
headers['X-Vault-Token'] = token;
if (options.wrapTTL) {
assign(options.headers, { 'X-Vault-Wrap-TTL': options.wrapTTL });
headers['X-Vault-Wrap-TTL'] = options.wrapTTL;
}
}
const isPolling = POLLING_URL_PATTERNS.some(str => url.includes(str));
let namespace =
typeof options.namespace === 'undefined' ? this.get('namespaceService.path') : options.namespace;
if (namespace && !NAMESPACE_ROOT_URLS.some(str => url.includes(str))) {
headers['X-Vault-Namespace'] = namespace;
}
options.headers = assign(options.headers || {}, headers);
},
_preRequest(url, options) {
this.addHeaders(url, options);
const isPolling = POLLING_URLS.some(str => url.includes(str));
if (!isPolling) {
this.get('auth').setLastFetch(Date.now());
}
@ -87,8 +99,8 @@ export default DS.RESTAdapter.extend({
rawRequest(url, type, options = {}) {
let opts = this._preRequest(url, options);
return fetch(url, {
method: type | 'GET',
headers: opts.headers | {},
method: type || 'GET',
headers: opts.headers || {},
}).then(response => {
if (response.status >= 200 && response.status < 300) {
return RSVP.resolve(response);

View File

@ -26,7 +26,9 @@ export default ApplicationAdapter.extend({
};
})
.catch(() => {
return [];
return {
data: {},
};
});
}
return this.ajax(this.url(), 'GET').catch(e => {

View File

@ -20,6 +20,7 @@ const REPLICATION_ENDPOINTS = {
const REPLICATION_MODES = ['dr', 'performance'];
export default ApplicationAdapter.extend({
version: inject.service(),
namespaceService: inject.service('namespace'),
shouldBackgroundReloadRecord() {
return true;
},
@ -28,7 +29,7 @@ export default ApplicationAdapter.extend({
health: this.health(),
sealStatus: this.sealStatus().catch(e => e),
};
if (this.get('version.isEnterprise')) {
if (this.get('version.isEnterprise') && this.get('namespaceService.inRootNamespace')) {
fetches.replicationStatus = this.replicationStatus().catch(e => e);
}
return Ember.RSVP.hash(fetches).then(({ health, sealStatus, replicationStatus }) => {

View File

@ -0,0 +1,34 @@
import ApplicationAdapter from './application';
export default ApplicationAdapter.extend({
pathForType() {
return 'namespaces';
},
urlForFindAll(modelName, snapshot) {
if (snapshot.adapterOptions && snapshot.adapterOptions.forUser) {
return `/${this.urlPrefix()}/internal/ui/namespaces`;
}
return `/${this.urlPrefix()}/namespaces?list=true`;
},
urlForCreateRecord(modelName, snapshot) {
let id = snapshot.attr('path');
return this.buildURL(modelName, id);
},
createRecord(store, type, snapshot) {
let id = snapshot.attr('path');
return this._super(...arguments).then(() => {
return { id };
});
},
findAll(store, type, sinceToken, snapshot) {
if (snapshot.adapterOptions && typeof snapshot.adapterOptions.namespace !== 'undefined') {
return this.ajax(this.urlForFindAll('namespace', snapshot), 'GET', {
namespace: snapshot.adapterOptions.namespace,
});
}
return this._super(...arguments);
},
});

View File

@ -65,9 +65,9 @@ export default ApplicationAdapter.extend({
return Ember.RSVP.hash({
backend: backendPath,
id: this.id(backendPath),
der: this.rawRequest(derURL, { unauthenticated: true }).then(response => response.blob()),
pem: this.rawRequest(pemURL, { unauthenticated: true }).then(response => response.text()),
ca_chain: this.rawRequest(chainURL, { unauthenticated: true }).then(response => response.text()),
der: this.rawRequest(derURL, 'GET', { unauthenticated: true }).then(response => response.blob()),
pem: this.rawRequest(pemURL, 'GET', { unauthenticated: true }).then(response => response.text()),
ca_chain: this.rawRequest(chainURL, 'GET', { unauthenticated: true }).then(response => response.text()),
});
},

7
ui/app/breakpoints.js Normal file
View File

@ -0,0 +1,7 @@
//https://github.com/jgthms/bulma/blob/6ad2e3df0589e5d6ff7a9c03ee1c78a546bedeaf/sass/utilities/initial-variables.sass#L48-L59
//https://github.com/jgthms/bulma/blob/6ad2e3df0589e5d6ff7a9c03ee1c78a546bedeaf/sass/utilities/mixins.sass#L71-L130
export default {
mobile: '(max-width: 768px)',
tablet: '(min-width: 769px)',
desktop: '(min-width: 1088px)',
};

View File

@ -12,7 +12,6 @@ const DEFAULTS = {
};
export default Ember.Component.extend(DEFAULTS, {
classNames: ['auth-form'],
router: inject.service(),
auth: inject.service(),
flashMessages: inject.service(),
@ -24,6 +23,30 @@ export default Ember.Component.extend(DEFAULTS, {
methods: null,
cluster: null,
redirectTo: null,
namespace: null,
// internal
oldNamespace: null,
didReceiveAttrs() {
this._super(...arguments);
let token = this.get('wrappedToken');
let newMethod = this.get('selectedAuth');
let oldMethod = this.get('oldSelectedAuth');
let ns = this.get('namespace');
let oldNS = this.get('oldNamespace');
if (oldNS === null || oldNS !== ns) {
this.get('fetchMethods').perform();
}
this.set('oldNamespace', ns);
if (oldMethod && oldMethod !== newMethod) {
this.resetDefaults();
}
this.set('oldSelectedAuth', newMethod);
if (token) {
this.get('unwrapToken').perform(token);
}
},
didRender() {
this._super(...arguments);
@ -35,10 +58,12 @@ export default Ember.Component.extend(DEFAULTS, {
// this is here because we're changing the `with` attr and there's no way to short-circuit rendering,
// so we'll just nav -> get new attrs -> re-render
if (!this.get('selectedAuth') || (this.get('selectedAuth') && !this.get('selectedAuthBackend'))) {
this.get('router').replaceWith('vault.cluster.auth', this.get('cluster.name'), {
this.set('selectedAuth', this.firstMethod());
this.get('router').replaceWith({
queryParams: {
with: this.firstMethod(),
wrappedToken: this.get('wrappedToken'),
namespace: this.get('namespace'),
},
});
}
@ -50,40 +75,27 @@ export default Ember.Component.extend(DEFAULTS, {
return get(firstMethod, 'path') || get(firstMethod, 'type');
},
didReceiveAttrs() {
this._super(...arguments);
let token = this.get('wrappedToken');
let newMethod = this.get('selectedAuth');
let oldMethod = this.get('oldSelectedAuth');
if (oldMethod && oldMethod !== newMethod) {
this.resetDefaults();
}
this.set('oldSelectedAuth', newMethod);
if (token) {
this.get('unwrapToken').perform(token);
}
},
resetDefaults() {
this.setProperties(DEFAULTS);
},
selectedAuthIsPath: computed.match('selectedAuth', /\/$/),
selectedAuthBackend: Ember.computed(
'allSupportedMethods',
'methods',
'methods.[]',
'selectedAuth',
'selectedAuthIsPath',
function() {
let methods = this.get('methods');
let selectedAuth = this.get('selectedAuth');
let keyIsPath = this.get('selectedAuthIsPath');
if (!methods) {
return {};
}
if (keyIsPath) {
return methods.findBy('path', selectedAuth);
} else {
return BACKENDS.findBy('type', selectedAuth);
}
return BACKENDS.findBy('type', selectedAuth);
}
),
@ -107,7 +119,7 @@ export default Ember.Component.extend(DEFAULTS, {
hasMethodsWithPath: computed('methodsToShow', function() {
return this.get('methodsToShow').isAny('path');
}),
methodsToShow: computed('methods', 'methods.[]', function() {
methodsToShow: computed('methods', function() {
let methods = this.get('methods') || [];
let shownMethods = methods.filter(m =>
BACKENDS.find(b => get(b, 'type').toLowerCase() === get(m, 'type').toLowerCase())
@ -128,6 +140,24 @@ export default Ember.Component.extend(DEFAULTS, {
}
}),
fetchMethods: task(function*() {
let store = this.get('store');
this.set('methods', null);
store.unloadAll('auth-method');
try {
let methods = yield store.findAll('auth-method', {
adapterOptions: {
unauthenticated: true,
},
});
this.set('methods', methods);
} catch (e) {
this.set('error', `There was an error fetching auth methods: ${e.errors[0]}`);
}
}),
showLoading: computed.or('fetchMethods.isRunning', 'unwrapToken.isRunning'),
handleError(e) {
this.set('loading', false);
let errors = e.errors.map(error => {
@ -149,9 +179,9 @@ export default Ember.Component.extend(DEFAULTS, {
let targetRoute = this.get('redirectTo') || 'vault.cluster';
let backend = this.get('selectedAuthBackend') || {};
let backendMeta = BACKENDS.find(
b => get(b, 'type').toLowerCase() === get(backend, 'type').toLowerCase()
b => (get(b, 'type') || '').toLowerCase() === (get(backend, 'type') || '').toLowerCase()
);
let attributes = get(backendMeta, 'formAttributes');
let attributes = get(backendMeta || {}, 'formAttributes') || {};
data = Ember.assign(data, this.getProperties(...attributes));
if (this.get('customPath') || get(backend, 'id')) {
@ -159,9 +189,9 @@ export default Ember.Component.extend(DEFAULTS, {
}
const clusterId = this.get('cluster.id');
this.get('auth').authenticate({ clusterId, backend: get(backend, 'type'), data }).then(
({ isRoot }) => {
({ isRoot, namespace }) => {
this.set('loading', false);
const transition = this.get('router').transitionTo(targetRoute);
const transition = this.get('router').transitionTo(targetRoute, { queryParams: { namespace } });
if (isRoot) {
transition.followRedirects().then(() => {
this.get('flashMessages').warning(

View File

@ -2,8 +2,6 @@ import Ember from 'ember';
import keys from 'vault/lib/keycodes';
export default Ember.Component.extend({
'data-test-component': 'console/command-input',
classNames: 'console-ui-input',
onExecuteCommand() {},
onFullscreen() {},
onValueUpdate() {},

View File

@ -11,12 +11,14 @@ export default Ember.Component.extend({
successMessage: 'Saved!',
deleteSuccessMessage: 'Deleted!',
deleteButtonText: 'Delete',
saveButtonText: 'Save',
cancelLink: null,
/*
* @param Function
* @public
*
* Optional param to call a function upon successfully saving an entity
* Optional param to call a function upon successfully saving a model
*/
onSave: () => {},

View File

@ -1,18 +1,8 @@
import Ember from 'ember';
import hbs from 'htmlbars-inline-precompile';
const { computed } = Ember;
export default Ember.Component.extend({
layout: hbs`<a href="{{href-to 'vault.cluster' 'vault'}}" class={{class}}>
{{#if hasBlock}}
{{yield}}
{{else}}
{{text}}
{{/if}}
</a>
`,
const { Component, computed } = Ember;
export default Component.extend({
tagName: '',
text: computed(function() {

View File

@ -0,0 +1,23 @@
import Ember from 'ember';
import { task } from 'ember-concurrency';
const { inject } = Ember;
export default Ember.Component.extend({
flashMessages: inject.service(),
tagName: '',
linkParams: null,
componentName: null,
hasMenu: false,
callMethod: task(function*(method, model, successMessage, failureMessage) {
let flash = this.get('flashMessages');
try {
yield model[method]();
flash.success(successMessage);
} catch (e) {
let errString = e.errors.join(' ');
flash.danger(failureMessage + errString);
model.rollbackAttributes();
}
}),
});

View File

@ -0,0 +1,5 @@
import Ember from 'ember';
export default Ember.Component.extend({
tagName: '',
});

View File

@ -0,0 +1,5 @@
import Ember from 'ember';
export default Ember.Component.extend({
tagName: '',
});

View File

@ -0,0 +1,14 @@
import Ember from 'ember';
import { pluralize } from 'ember-inflector';
const { computed } = Ember;
export default Ember.Component.extend({
tagName: '',
items: null,
itemNoun: 'item',
emptyMessage: computed('itemNoun', function() {
let items = pluralize(this.get('itemNoun'));
return `There are currently no ${items}`;
}),
});

View File

@ -7,7 +7,7 @@ export default Ember.Component.extend({
mounts: null,
// singleton mounts are not eligible for per-mount-filtering
singletonMountTypes: ['cubbyhole', 'system', 'token', 'identity'],
singletonMountTypes: ['cubbyhole', 'system', 'token', 'identity', 'ns_system', 'ns_identity'],
actions: {
addOrRemovePath(path, e) {

View File

@ -0,0 +1,32 @@
import Ember from 'ember';
const { Component, computed, inject } = Ember;
export default Component.extend({
namespaceService: inject.service('namespace'),
currentNamespace: computed.alias('namespaceService.path'),
tagName: '',
//public api
targetNamespace: null,
showLastSegment: false,
normalizedNamespace: computed('targetNamespace', function() {
let ns = this.get('targetNamespace');
return (ns || '').replace(/\.+/g, '/').replace('☃', '.');
}),
namespaceDisplay: computed('normalizedNamespace', 'showLastSegment', function() {
let ns = this.get('normalizedNamespace');
let showLastSegment = this.get('showLastSegment');
let parts = ns.split('/');
if (ns === '') {
return 'root';
}
return showLastSegment ? parts[parts.length - 1] : ns;
}),
isCurrentNamespace: computed('targetNamespace', 'currentNamespace', function() {
return this.get('currentNamespace') === this.get('targetNamespace');
}),
});

View File

@ -0,0 +1,141 @@
import Ember from 'ember';
import keyUtils from 'vault/lib/key-utils';
import pathToTree from 'vault/lib/path-to-tree';
import { task, timeout } from 'ember-concurrency';
const { ancestorKeysForKey } = keyUtils;
const { Component, computed, inject } = Ember;
const DOT_REPLACEMENT = '☃';
const ANIMATION_DURATION = 250;
export default Component.extend({
tagName: '',
namespaceService: inject.service('namespace'),
auth: inject.service(),
namespace: null,
init() {
this._super(...arguments);
this.get('namespaceService.findNamespacesForUser').perform();
},
didReceiveAttrs() {
this._super(...arguments);
let ns = this.get('namespace');
let oldNS = this.get('oldNamespace');
if (!oldNS || ns !== oldNS) {
this.get('setForAnimation').perform();
}
this.set('oldNamespace', ns);
},
setForAnimation: task(function*() {
let leaves = this.get('menuLeaves');
let lastLeaves = this.get('lastMenuLeaves');
if (!lastLeaves) {
this.set('lastMenuLeaves', leaves);
yield timeout(0);
return;
}
let isAdding = leaves.length > lastLeaves.length;
let changedLeaf = (isAdding ? leaves : lastLeaves).get('lastObject');
this.set('isAdding', isAdding);
this.set('changedLeaf', changedLeaf);
// if we're adding we want to render immediately an animate it in
// if we're not adding, we need time to move the item out before
// a rerender removes it
if (isAdding) {
this.set('lastMenuLeaves', leaves);
yield timeout(0);
return;
}
yield timeout(ANIMATION_DURATION);
this.set('lastMenuLeaves', leaves);
}).drop(),
isAnimating: computed.alias('setForAnimation.isRunning'),
namespacePath: computed.alias('namespaceService.path'),
// this is an array of namespace paths that the current user
// has access to
accessibleNamespaces: computed.alias('namespaceService.accessibleNamespaces'),
inRootNamespace: computed.alias('namespaceService.inRootNamespace'),
namespaceTree: computed('accessibleNamespaces', function() {
let nsList = this.get('accessibleNamespaces');
if (!nsList) {
return [];
}
return pathToTree(nsList);
}),
maybeAddRoot(leaves) {
let userRoot = this.get('auth.authData.userRootNamespace');
if (userRoot === '') {
leaves.unshift('');
}
return leaves.uniq();
},
pathToLeaf(path) {
// dots are allowed in namespace paths
// so we need to preserve them, and replace slashes with dots
// in order to use Ember's get function on the namespace tree
// to pull out the correct level
return (
path
// trim trailing slash
.replace(/\/$/, '')
// replace dots with snowman
.replace(/\.+/g, DOT_REPLACEMENT)
// replace slash with dots
.replace(/\/+/g, '.')
);
},
// an array that keeps track of what additional panels to render
// on the menu stack
// if you're in 'foo/bar/baz',
// this array will be: ['foo', 'foo.bar', 'foo.bar.baz']
// the template then iterates over this, and does Ember.get(namespaceTree, leaf)
// to render the nodes of each leaf
// gets set as 'lastMenuLeaves' in the ember concurrency task above
menuLeaves: computed('namespacePath', 'namespaceTree', function() {
let ns = this.get('namespacePath');
let leaves = ancestorKeysForKey(ns) || [];
leaves.push(ns);
leaves = this.maybeAddRoot(leaves);
leaves = leaves.map(this.pathToLeaf);
return leaves;
}),
// the nodes at the root of the namespace tree
// these will get rendered as the bottom layer
rootLeaves: computed('namespaceTree', function() {
let tree = this.get('namespaceTree');
let leaves = Object.keys(tree);
return leaves;
}),
currentLeaf: computed.alias('lastMenuLeaves.lastObject'),
canAccessMultipleNamespaces: computed.gt('accessibleNamespaces.length', 1),
isUserRootNamespace: computed('auth.authData.userRootNamespace', 'namespacePath', function() {
return this.get('auth.authData.userRootNamespace') === this.get('namespacePath');
}),
namespaceDisplay: computed('namespacePath', 'accessibleNamespaces', 'accessibleNamespaces.[]', function() {
let namespace = this.get('namespacePath');
if (namespace === '') {
return '';
}
let parts = namespace.split('/');
return parts[parts.length - 1];
}),
});

View File

@ -0,0 +1,18 @@
import Ember from 'ember';
const { Component, inject, computed } = Ember;
export default Component.extend({
namespace: inject.service(),
showMessage: computed.not('namespace.inRootNamespace'),
//public API
noun: null,
mode: 'edit',
modeVerb: computed(function() {
let mode = this.get('mode');
if (!mode) {
return '';
}
return mode.endsWith('e') ? `${mode}d` : `${mode}ed`;
}),
});

View File

@ -1,6 +1,5 @@
import Ember from 'ember';
import { hrefTo } from 'vault/helpers/href-to';
const { computed, get, getProperties } = Ember;
const { computed, get, getProperties, Component, inject } = Ember;
const replicationAttr = function(attr) {
return computed('mode', `cluster.{dr,performance}.${attr}`, function() {
@ -8,8 +7,10 @@ const replicationAttr = function(attr) {
return get(cluster, `${mode}.${attr}`);
});
};
export default Ember.Component.extend({
version: Ember.inject.service(),
export default Component.extend({
version: inject.service(),
router: inject.service(),
namespace: inject.service(),
classNames: ['level', 'box-label'],
classNameBindings: ['isMenu:is-mobile'],
attributeBindings: ['href', 'target'],
@ -22,7 +23,12 @@ export default Ember.Component.extend({
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 this.get('router').urlFor(
'vault.cluster.replication.mode.index',
this.get('cluster.name'),
mode,
{ queryParams: { namespace: this.get('namespace.path') } }
);
}
return null;
}),

View File

@ -1,7 +1,5 @@
import Ember from 'ember';
import hbs from 'htmlbars-inline-precompile';
import { hrefTo } from 'vault/helpers/href-to';
const { computed } = Ember;
const { computed, Component } = Ember;
export function linkParams({ mode, secret, queryParams }) {
let params;
@ -20,7 +18,8 @@ export function linkParams({ mode, secret, queryParams }) {
return params;
}
export default Ember.Component.extend({
export default Component.extend({
tagName: '',
mode: 'list',
secret: null,
@ -28,16 +27,7 @@ export default Ember.Component.extend({
ariaLabel: null,
linkParams: computed('mode', 'secret', 'queryParams', function() {
return linkParams(this.getProperties('mode', 'secret', 'queryParams'));
let data = this.getProperties('mode', 'secret', 'queryParams');
return linkParams(data);
}),
attributeBindings: ['href', 'aria-label:ariaLabel'],
href: computed('linkParams', function() {
return hrefTo(this, ...this.get('linkParams'));
}),
layout: hbs`{{yield}}`,
tagName: 'a',
});

View File

@ -1,41 +1,16 @@
import Ember from 'ember';
import config from '../config/environment';
const { computed, inject } = Ember;
export default Ember.Controller.extend({
const { Controller, computed, inject } = Ember;
export default Controller.extend({
env: config.environment,
auth: inject.service(),
vaultVersion: inject.service('version'),
console: inject.service(),
consoleOpen: computed.alias('console.isOpen'),
store: inject.service(),
activeCluster: computed('auth.activeCluster', function() {
return this.store.peekRecord('cluster', this.get('auth.activeCluster'));
return this.get('store').peekRecord('cluster', this.get('auth.activeCluster'));
}),
activeClusterName: computed('auth.activeCluster', function() {
const activeCluster = this.store.peekRecord('cluster', this.get('auth.activeCluster'));
activeClusterName: computed('activeCluster', function() {
const activeCluster = this.get('activeCluster');
return activeCluster ? activeCluster.get('name') : null;
}),
showNav: 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;
}
}
),
actions: {
toggleConsole() {
this.toggleProperty('consoleOpen');
},
},
});

View File

@ -1,8 +1,63 @@
import Ember from 'ember';
const { inject, Controller } = Ember;
const { Controller, computed, observer, inject } = Ember;
export default Controller.extend({
auth: inject.service(),
version: inject.service(),
store: inject.service(),
media: inject.service(),
namespaceService: inject.service('namespace'),
vaultVersion: inject.service('version'),
console: inject.service(),
queryParams: [
{
namespaceQueryParam: {
scope: 'controller',
as: 'namespace',
},
},
],
namespaceQueryParam: '',
onQPChange: observer('namespaceQueryParam', function() {
this.get('namespaceService').setNamespace(this.get('namespaceQueryParam'));
}),
consoleOpen: computed.alias('console.isOpen'),
activeCluster: computed('auth.activeCluster', function() {
return this.get('store').peekRecord('cluster', this.get('auth.activeCluster'));
}),
activeClusterName: computed('activeCluster', function() {
const activeCluster = this.get('activeCluster');
return activeCluster ? activeCluster.get('name') : null;
}),
showNav: 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;
}
}
),
actions: {
toggleConsole() {
this.toggleProperty('consoleOpen');
},
},
});

View File

@ -0,0 +1,15 @@
import Ember from 'ember';
const { inject, Controller } = Ember;
export default Controller.extend({
namespaceService: inject.service('namespace'),
actions: {
onSave({ saveType }) {
if (saveType === 'save') {
// fetch new namespaces for the namespace picker
this.get('namespaceService.findNamespacesForUser').perform();
return this.transitionToRoute('vault.cluster.access.namespaces.index');
}
},
},
});

View File

@ -1,9 +1,20 @@
import Ember from 'ember';
import { task, timeout } from 'ember-concurrency';
export default Ember.Controller.extend({
vaultController: Ember.inject.controller('vault'),
const { inject, computed, Controller } = Ember;
export default Controller.extend({
vaultController: inject.controller('vault'),
clusterController: inject.controller('vault.cluster'),
namespaceService: inject.service('namespace'),
namespaceQueryParam: computed.alias('clusterController.namespaceQueryParam'),
queryParams: [{ authMethod: 'with' }],
wrappedToken: Ember.computed.alias('vaultController.wrappedToken'),
wrappedToken: computed.alias('vaultController.wrappedToken'),
authMethod: '',
redirectTo: null,
updateNamespace: task(function*(value) {
yield timeout(200);
this.get('namespaceService').setNamespace(value, true);
this.set('namespaceQueryParam', value);
}).restartable(),
});

View File

@ -8,7 +8,7 @@ export default Controller.extend({
supportedBackends: computed('displayableBackends', 'displayableBackends.[]', function() {
return (this.get('displayableBackends') || [])
.filter(backend => LINKED_BACKENDS.includes(backend.get('type')))
.filter(backend => LINKED_BACKENDS.includes(backend.get('engineType')))
.sortBy('id');
}),

View File

@ -0,0 +1,6 @@
import Ember from 'ember';
const { inject, Controller } = Ember;
export default Controller.extend({
namespaceService: inject.service('namespace'),
});

View File

@ -11,6 +11,7 @@ const FEATURES = [
'GCP CKMS Autounseal',
'Seal Wrapping',
'Control Groups',
'Namespaces',
];
export function hasFeature(featureName, features) {

View File

@ -0,0 +1,51 @@
import flat from 'flat';
import deepmerge from 'deepmerge';
const { unflatten } = flat;
const DOT_REPLACEMENT = '☃';
//function that takes a list of path and returns a deeply nested object
//representing a tree of all of those paths
//
//
// given ["foo", "bar", "foo1", "foo/bar", "foo/baz", "foo/bar/baz"]
//
// returns {
// bar: null,
// foo: {
// bar: {
// baz: null
// },
// baz: null,
// },
// foo1: null,
// }
export default function(paths) {
// first sort the list by length, then alphanumeric
let list = paths.slice(0).sort((a, b) => b.length - a.length || b.localeCompare(a));
// then reduce to an array
// and we remove all of the items that have a string
// that starts with the same prefix from the list
// so if we have "foo/bar/baz", both "foo" and "foo/bar"
// won't be included in the list
let tree = list.reduce((accumulator, ns) => {
let nsWithPrefix = accumulator.find(path => path.startsWith(ns));
// we need to make sure it's a match for the full path part
let isFullMatch = nsWithPrefix && nsWithPrefix.charAt(ns.length) === '/';
if (!isFullMatch) {
accumulator.push(ns);
}
return accumulator;
}, []);
// after the reduction we're left with an array that contains
// strings that represent the longest branches
// we'll replace the dots in the paths, then expand the path
// to a nested object that we can then query with Ember.get
return deepmerge.all(
tree.map(p => {
p = p.replace(/\.+/g, DOT_REPLACEMENT);
return unflatten({ [p]: null }, { delimiter: '/' });
})
);
}

View File

@ -1,3 +1,16 @@
// usage:
//
// import lazyCapabilities, { apiPath } from 'vault/macros/lazy-capabilities';
//
// export default DS.Model.extend({
// //pass the template string as the first arg, and be sure to use '' around the
// //paramerters that get interpolated in the string - that's how the template function
// //knows where to put each value
// zeroAddressPath: lazyCapabilities(apiPath`${'id'}/config/zeroaddress`, 'id'),
//
// });
//
import { queryRecord } from 'ember-computed-query';
export function apiPath(strings, ...keys) {

View File

@ -1,6 +1,6 @@
import Ember from 'ember';
const { get } = Ember;
const { get, inject, Mixin, RSVP } = Ember;
const INIT = 'vault.cluster.init';
const UNSEAL = 'vault.cluster.unseal';
const AUTH = 'vault.cluster.auth';
@ -9,15 +9,16 @@ 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(),
export default Mixin.create({
auth: inject.service(),
transitionToTargetRoute() {
const targetRoute = this.targetRouteName();
if (targetRoute && targetRoute !== this.routeName) {
return this.transitionTo(targetRoute);
}
return Ember.RSVP.resolve();
return RSVP.resolve();
},
beforeModel() {

View File

@ -5,6 +5,7 @@ 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';
import lazyCapabilities, { apiPath } from 'vault/macros/lazy-capabilities';
const { attr, hasMany } = DS;
const { computed } = Ember;
@ -27,6 +28,11 @@ export default DS.Model.extend({
defaultValue: METHODS[0].value,
possibleValues: METHODS,
}),
// namespaces introduced types with a `ns_` prefix for built-in engines
// so we need to strip that to normalize the type
methodType: computed('type', function() {
return this.get('type').replace(/^ns_/, '');
}),
description: attr('string', {
editType: 'textarea',
}),
@ -108,17 +114,8 @@ export default DS.Model.extend({
'id',
'configPathTmpl'
),
deletePath: queryRecord(
'capabilities',
context => {
const { id } = context.get('id');
return {
id: `sys/auth/${id}`,
};
},
'id'
),
canDisable: computed.alias('deletePath.canDelete'),
deletePath: lazyCapabilities(apiPath`sys/auth/${'id'}`, 'id'),
canDisable: computed.alias('deletePath.canDelete'),
canEdit: computed.alias('configPath.canUpdate'),
});

View File

@ -0,0 +1,22 @@
import Ember from 'ember';
import DS from 'ember-data';
const { attr } = DS;
const { computed } = Ember;
import { expandAttributeMeta } from 'vault/utils/field-to-attrs';
export default DS.Model.extend({
path: attr('string', {
validationAttr: 'pathIsValid',
invalidMessage: 'You have entered and invalid path please only include letters, numbers, -, ., and _.',
}),
pathIsValid: computed('path', function() {
return this.get('path') && this.get('path').match(/^[\w\d-.]+$/g);
}),
description: attr('string', {
editType: 'textarea',
}),
fields: computed(function() {
return expandAttributeMeta(this, ['path']);
}),
});

View File

@ -23,8 +23,8 @@ export default DS.Model.extend({
local: attr('boolean'),
sealWrap: attr('boolean'),
modelTypeForKV: computed('type', 'options.version', function() {
let type = this.get('type');
modelTypeForKV: computed('engineType', 'options.version', function() {
let type = this.get('engineType');
let version = this.get('options.version');
let modelType = 'secret';
if ((type === 'kv' || type === 'generic') && version === 2) {
@ -48,8 +48,14 @@ export default DS.Model.extend({
return expandAttributeMeta(this, this.get('formFields'));
}),
shouldIncludeInList: computed('type', function() {
return !LIST_EXCLUDED_BACKENDS.includes(this.get('type'));
// namespaces introduced types with a `ns_` prefix for built-in engines
// so we need to strip that to normalize the type
engineType: computed('type', function() {
return (this.get('type') || '').replace(/^ns_/, '');
}),
shouldIncludeInList: computed('engineType', function() {
return !LIST_EXCLUDED_BACKENDS.includes(this.get('engineType'));
}),
localDisplay: Ember.computed('local', function() {

View File

@ -66,6 +66,10 @@ Router.map(function() {
});
this.route('control-groups');
this.route('control-group-accessor', { path: '/control-groups/:accessor' });
this.route('namespaces', function() {
this.route('index', { path: '/' });
this.route('create');
});
});
this.route('secrets', function() {
this.route('backends', { path: '/' });

View File

@ -4,19 +4,55 @@ import ControlGroupError from 'vault/lib/control-group-error';
const { inject } = Ember;
export default Ember.Route.extend({
controlGroup: inject.service(),
routing: inject.service('router'),
namespaceService: inject.service('namespace'),
actions: {
willTransition() {
window.scrollTo(0, 0);
},
error(err, transition) {
error(error, transition) {
let controlGroup = this.get('controlGroup');
if (err instanceof ControlGroupError) {
return controlGroup.handleError(err, transition);
if (error instanceof ControlGroupError) {
return controlGroup.handleError(error, transition);
}
if (err.path === '/v1/sys/wrapping/unwrap') {
if (error.path === '/v1/sys/wrapping/unwrap') {
controlGroup.unmarkTokenForUnwrap();
}
let router = this.get('routing');
let errorURL = transition.intent.url;
let { name, contexts, queryParams } = transition.intent;
// If the transition is internal to Ember, we need to generate the URL
// from the route parameters ourselves
if (!errorURL) {
try {
errorURL = router.urlFor(name, ...(contexts || []), { queryParams });
} catch (e) {
// If this fails, something weird is happening with URL transitions
errorURL = null;
}
}
// because we're using rootURL, we need to trim this from the front to get
// the ember-routeable url
if (errorURL) {
errorURL = errorURL.replace('/ui', '');
}
error.errorURL = errorURL;
// if we have queryParams, update the namespace so that the observer can fire on the controller
if (queryParams) {
this.controllerFor('vault.cluster').set('namespaceQueryParam', queryParams.namespace || '');
}
// Assuming we have a URL, push it into browser history and update the
// location bar for the user
if (errorURL) {
router.get('location').setURL(errorURL);
}
return true;
},
},

View File

@ -3,14 +3,22 @@ import ClusterRoute from 'vault/mixins/cluster-route';
import ModelBoundaryRoute from 'vault/mixins/model-boundary-route';
const POLL_INTERVAL_MS = 10000;
const { inject } = Ember;
const { inject, Route, getOwner } = Ember;
export default Ember.Route.extend(ModelBoundaryRoute, ClusterRoute, {
export default Route.extend(ModelBoundaryRoute, ClusterRoute, {
namespaceService: inject.service('namespace'),
version: inject.service(),
store: inject.service(),
auth: inject.service(),
currentCluster: Ember.inject.service(),
currentCluster: inject.service(),
modelTypes: ['node', 'secret', 'secret-engine'],
globalNamespaceModels: ['node', 'cluster'],
queryParams: {
namespaceQueryParam: {
refreshModel: true,
},
},
getClusterId(params) {
const { cluster_name } = params;
@ -18,8 +26,24 @@ export default Ember.Route.extend(ModelBoundaryRoute, ClusterRoute, {
return cluster ? cluster.get('id') : null;
},
clearNonGlobalModels() {
// this method clears all of the ember data cached models except
// the model types blacklisted in `globalNamespaceModels`
let store = this.store;
let modelsToKeep = this.get('globalNamespaceModels');
for (let model of getOwner(this).lookup('data-adapter:main').getModelTypes()) {
let { name } = model;
if (modelsToKeep.includes(name)) {
return;
}
store.unloadAll(name);
}
},
beforeModel() {
const params = this.paramsFor(this.routeName);
this.clearNonGlobalModels();
this.get('namespaceService').setNamespace(params.namespaceQueryParam);
const id = this.getClusterId(params);
if (id) {
this.get('auth').setCluster(id);
@ -61,6 +85,12 @@ export default Ember.Route.extend(ModelBoundaryRoute, ClusterRoute, {
this.get('currentCluster').setCluster(model);
this._super(...arguments);
this.poll();
// Check that namespaces is enabled and if not,
// clear the namespace by transition to this route w/o it
if (this.get('namespaceService.path') && !this.get('version.hasNamespaces')) {
return this.transitionTo(this.routeName, { queryParams: { namespace: '' } });
}
return this.transitionToTargetRoute();
},

View File

@ -0,0 +1,16 @@
import Ember from 'ember';
import UnloadModel from 'vault/mixins/unload-model-route';
const { inject } = Ember;
export default Ember.Route.extend(UnloadModel, {
version: inject.service(),
beforeModel() {
return this.get('version').fetchFeatures().then(() => {
return this._super(...arguments);
});
},
model() {
return this.get('version.hasNamespaces') ? this.store.createRecord('namespace') : null;
},
});

View File

@ -0,0 +1,24 @@
import Ember from 'ember';
import UnloadModel from 'vault/mixins/unload-model-route';
const { inject } = Ember;
export default Ember.Route.extend(UnloadModel, {
version: inject.service(),
beforeModel() {
this.store.unloadAll('namespace');
return this.get('version').fetchFeatures().then(() => {
return this._super(...arguments);
});
},
model() {
return this.get('version.hasNamespaces')
? this.store.findAll('namespace').catch(e => {
if (e.httpStatus === 404) {
return [];
}
throw e;
})
: null;
},
});

View File

@ -2,28 +2,18 @@ import ClusterRouteBase from './cluster-route-base';
import Ember from 'ember';
import config from 'vault/config/environment';
const { RSVP, inject } = Ember;
const { inject } = Ember;
export default ClusterRouteBase.extend({
flashMessages: inject.service(),
version: inject.service(),
beforeModel() {
this.store.unloadAll('auth-method');
return this._super();
return this._super().then(() => {
return this.get('version').fetchFeatures();
});
},
model() {
let cluster = this._super(...arguments);
return this.store
.findAll('auth-method', {
adapterOptions: {
unauthenticated: true,
},
})
.then(result => {
return RSVP.hash({
cluster,
methods: result,
});
});
return this._super(...arguments);
},
resetController(controller) {
controller.set('wrappedToken', '');

View File

@ -22,7 +22,7 @@ export default Ember.Route.extend({
let { backend } = this.paramsFor('vault.cluster.secrets.backend');
let { secret } = this.paramsFor(this.routeName);
let backendModel = this.store.peekRecord('secret-engine', backend);
let type = backendModel && backendModel.get('type');
let type = backendModel && backendModel.get('engineType');
if (!type || !SUPPORTED_BACKENDS.includes(type)) {
return this.transitionTo('vault.cluster.secrets');
}
@ -33,7 +33,7 @@ export default Ember.Route.extend({
getModelType(backend, tab) {
let backendModel = this.store.peekRecord('secret-engine', backend);
let type = backendModel.get('type');
let type = backendModel.get('engineType');
let types = {
transit: 'transit-key',
ssh: 'role-ssh',
@ -115,7 +115,7 @@ export default Ember.Route.extend({
backend,
backendModel,
baseKey: { id: secret },
backendType: backendModel.get('type'),
backendType: backendModel.get('engineType'),
});
if (!has404) {
const pageFilter = secretParams.pageFilter;

View File

@ -6,7 +6,7 @@ export default Ember.Route.extend(UnloadModelRoute, {
capabilities(secret) {
const { backend } = this.paramsFor('vault.cluster.secrets.backend');
let backendModel = this.modelFor('vault.cluster.secrets.backend');
let backendType = backendModel.get('type');
let backendType = backendModel.get('engineType');
let version = backendModel.get('options.version');
let path;
if (backendType === 'transit') {
@ -22,7 +22,7 @@ export default Ember.Route.extend(UnloadModelRoute, {
},
backendType() {
return this.modelFor('vault.cluster.secrets.backend').get('type');
return this.modelFor('vault.cluster.secrets.backend').get('engineType');
},
templateName: 'vault/cluster/secrets/backend/secretEditLayout',
@ -45,7 +45,7 @@ export default Ember.Route.extend(UnloadModelRoute, {
modelType(backend, secret) {
let backendModel = this.modelFor('vault.cluster.secrets.backend', backend);
let type = backendModel.get('type');
let type = backendModel.get('engineType');
let types = {
transit: 'transit-key',
ssh: 'role-ssh',

View File

@ -10,7 +10,7 @@ export default Ember.Route.extend({
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');
const modelType = model && model.get('methodType');
if (!model || (modelType !== 'token' && !METHODS.findBy('type', modelType))) {
const error = new DS.AdapterError();
Ember.set(error, 'httpStatus', 404);

View File

@ -0,0 +1,24 @@
import ApplicationSerializer from './application';
export default ApplicationSerializer.extend({
normalizeList(payload) {
const data = payload.data.keys
? payload.data.keys.map(key => ({
path: key,
// remove the trailing slash from the id
id: key.replace(/\/$/, ''),
}))
: payload.data;
return data;
},
normalizeResponse(store, primaryModelClass, payload, id, requestType) {
const nullResponses = ['deleteRecord', 'createRecord'];
let cid = (id || payload.id || '').replace(/\/$/, '');
let normalizedPayload = nullResponses.includes(requestType)
? { id: cid, path: cid }
: this.normalizeList(payload);
return this._super(store, primaryModelClass, normalizedPayload, id, requestType);
},
});

View File

@ -3,7 +3,7 @@ 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 { get, isArray, computed, getOwner, Service, inject } = Ember;
const TOKEN_SEPARATOR = '☃';
const TOKEN_PREFIX = 'vault-';
@ -13,7 +13,8 @@ const BACKENDS = supportedAuthBackends();
export { TOKEN_SEPARATOR, TOKEN_PREFIX, ROOT_PREFIX };
export default Ember.Service.extend({
export default Service.extend({
namespace: inject.service(),
expirationCalcTS: null,
init() {
this._super(...arguments);
@ -69,17 +70,25 @@ export default Ember.Service.extend({
'X-Vault-Token': this.get('currentToken'),
},
};
let namespace =
typeof options.namespace === 'undefined' ? this.get('namespaceService.path') : options.namespace;
if (namespace) {
defaults.headers['X-Vault-Namespace'] = namespace;
}
return Ember.$.ajax(Ember.assign(defaults, options));
},
renewCurrentToken() {
let namespace = this.get('authData.userRootNamespace');
const url = '/v1/auth/token/renew-self';
return this.ajax(url, 'POST');
return this.ajax(url, 'POST', { namespace });
},
revokeCurrentToken() {
let namespace = this.get('authData.userRootNamespace');
const url = '/v1/auth/token/revoke-self';
return this.ajax(url, 'POST');
return this.ajax(url, 'POST', { namespace });
},
calculateExpiration(resp, creationTime) {
@ -97,8 +106,9 @@ export default Ember.Service.extend({
},
persistAuthData() {
const [firstArg, resp] = arguments;
let [firstArg, resp] = arguments;
let tokens = this.get('tokens');
let currentNamespace = this.get('namespace.path') || '';
let tokenName;
let options;
let backend;
@ -110,7 +120,7 @@ export default Ember.Service.extend({
backend = options.backend;
}
const currentBackend = BACKENDS.findBy('type', backend);
let currentBackend = BACKENDS.findBy('type', backend);
let displayName;
if (isArray(currentBackend.displayNamePath)) {
displayName = currentBackend.displayNamePath.map(name => get(resp, name)).join('/');
@ -118,8 +128,26 @@ export default Ember.Service.extend({
displayName = get(resp, currentBackend.displayNamePath);
}
const { entity_id, policies, renewable } = resp;
let { entity_id, policies, renewable, namespace_path } = resp;
// here we prefer namespace_path if its defined,
// else we look and see if there's already a namespace saved
// and then finally we'll use the current query param if the others
// haven't set a value yet
// all of the typeof checks are necessary because the root namespace is ''
let userRootNamespace = namespace_path && namespace_path.replace(/\/$/, '');
// if we're logging in with token and there's no namespace_path, we can assume
// that the token belongs to the root namespace
if (backend === 'token' && !userRootNamespace) {
userRootNamespace = '';
}
if (typeof userRootNamespace === 'undefined') {
userRootNamespace = this.get('authData.userRootNamespace');
}
if (typeof userRootNamespace === 'undefined') {
userRootNamespace = currentNamespace;
}
let data = {
userRootNamespace,
displayName,
backend: currentBackend,
token: resp.client_token || get(resp, currentBackend.tokenPath),
@ -148,6 +176,7 @@ export default Ember.Service.extend({
this.set('allowExpiration', false);
this.setTokenData(tokenName, data);
return Ember.RSVP.resolve({
namespace: currentNamespace || data.userRootNamespace,
token: tokenName,
isRoot: policies.includes('root'),
});
@ -253,7 +282,7 @@ export default Ember.Service.extend({
const adapter = this.clusterAdapter();
return adapter.authenticate(options).then(resp => {
return this.persistAuthData(options, resp.auth || resp.data);
return this.persistAuthData(options, resp.auth || resp.data, this.get('namespace.path'));
});
},
@ -269,6 +298,7 @@ export default Ember.Service.extend({
this.set('tokens', tokenNames);
},
// returns the key for the token to use
currentTokenName: computed('activeCluster', 'tokens', 'tokens.[]', function() {
const regex = new RegExp(this.get('activeCluster'));
return this.get('tokens').find(key => regex.test(key));

View File

@ -0,0 +1,40 @@
import Ember from 'ember';
import { task } from 'ember-concurrency';
const { Service, computed, inject } = Ember;
const ROOT_NAMESPACE = '';
export default Service.extend({
store: inject.service(),
auth: inject.service(),
userRootNamespace: computed.alias('auth.authData.userRootNamespace'),
//populated by the query param on the cluster route
path: null,
// list of namespaces available to the current user under the
// current namespace
accessibleNamespaces: null,
inRootNamespace: computed.equal('path', ROOT_NAMESPACE),
setNamespace(path) {
this.set('path', path);
},
findNamespacesForUser: task(function*() {
// uses the adapter and the raw response here since
// models get wiped when switching namespaces and we
// want to keep track of these separately
let store = this.get('store');
let adapter = store.adapterFor('namespace');
try {
let ns = yield adapter.findAll(store, 'namespace', null, {
adapterOptions: {
forUser: true,
namespace: this.get('userRootNamespace'),
},
});
this.set('accessibleNamespaces', ns.data.keys.map(n => n.replace(/\/$/, '')));
} catch (e) {
//do nothing here
}
}).drop(),
});

View File

@ -23,6 +23,7 @@ export default Service.extend({
hasDRReplication: hasFeature('DR Replication'),
hasSentinel: hasFeature('Sentinel'),
hasNamespaces: hasFeature('Namespaces'),
isEnterprise: computed.match('version', /\+.+$/),

View File

@ -2,4 +2,18 @@
@extend .box;
@extend .is-bottomless;
padding: 0;
position: relative;
}
.auth-form .vault-loader {
position: absolute;
top: -1px;
left: -1px;
right: -1px;
bottom: -1px;
background: rgba(255, 255, 255, 0.8);
z-index: 10;
display: flex;
justify-content: center;
align-items: center;
}

View File

@ -0,0 +1,102 @@
.namespace-picker {
border-right: 1px solid rgba($black, 0.5);
margin-right: $size-10;
position: relative;
padding: 0.5rem;
color: $white;
fill: $white;
}
.namespace-picker.no-namespaces {
border: none;
padding-right: 0;
}
.namespace-picker-trigger {
display: flex;
align-items: center;
}
.namespace-name {
display: inline-block;
margin-left: $size-10;
font-size: 1rem;
}
.namespace-picker-content {
width: 300px;
max-height: 300px;
overflow: auto;
border-radius: $radius;
box-shadow: $box-shadow, $box-shadow-high;
}
.namespace-picker-content .level-left {
max-width: 210px;
overflow-wrap: break-word;
word-wrap: break-word;
-ms-word-break: break-all;
word-break: break-all;
word-break: break-word;
}
.namespace-header-bar {
padding: $size-11 $size-10;
box-shadow: $box-shadow;
font-weight: $font-weight-semibold;
min-height: 32px;
.namespace-manage-link {
text-decoration: none;
}
}
.namespace-header {
margin: $size-9 $size-9 0;
color: $grey;
font-size: $size-8;
font-weight: $font-weight-semibold;
text-transform: uppercase;
}
.current-namespace {
border-bottom: 1px solid rgba($black, 0.1);
}
.namespace-list {
position: relative;
overflow: hidden;
}
.namespace-link {
color: $black;
text-decoration: none;
font-weight: $font-weight-semibold;
padding: $size-10 $size-9;
}
.leaf-panel {
transition: transform ease-in-out 250ms;
will-change: transform;
transform: translateX(0);
background: $white;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 1;
}
.leaf-panel-left {
transform: translateX(-300px);
}
.leaf-panel-adding,
.leaf-panel-current {
position: relative;
& .namespace-link:last-child {
margin-bottom: 4px;
}
}
.animated-list {
.leaf-panel-exiting,
.leaf-panel-adding {
transform: translateX(300px);
z-index: 20;
}
}
.leaf-panel-adding {
z-index: 100;
}

View File

@ -0,0 +1,12 @@
.namespace-reminder {
color: $grey;
margin: 0 0 $size-6 0;
}
.console-reminder p.namespace-reminder {
margin-bottom: 0;
opacity: 0.7;
position: absolute;
color: $grey;
font-family: $family-monospace;
}

View File

@ -3,13 +3,19 @@ a.splash-page-logo {
svg {
transform: scale(.5);
transform-origin: left;
fill: currentColor;
fill: $white;
}
}
a.splash-page-logo.is-active {
background: transparent;
}
.splash-page-container {
margin: $size-2 0;
}
.splash-page-header {
padding: .75rem 1.5rem;
padding: $size-6 $size-5;
}
.splash-page-sub-header {
margin: 0 $size-5 $size-6;
}

View File

@ -62,6 +62,8 @@
@import "./components/login-form";
@import "./components/masked-input";
@import "./components/message-in-page";
@import "./components/namespace-picker";
@import "./components/namespace-reminder";
@import "./components/page-header";
@import "./components/popup-menu";
@import "./components/radial-progress";

View File

@ -199,6 +199,17 @@ $button-box-shadow-standard: 0 3px 1px 0 rgba($black, 0.12);
}
}
.button.icon {
box-sizing: border-box;
padding: 0 $size-11;
height: 24px;
width: 24px;
&,
& .icon {
min-width: 0;
}
}
.button .icon.auto-width {
width: auto;
margin: 0 !important;

View File

@ -43,6 +43,9 @@ a.navbar-item {
left: 3.5em;
top: 0;
height: 3.25rem;
&.with-ns-picker {
left: 0;
}
}
.icon.edition-icon {

View File

@ -1,120 +1,5 @@
<div class="page-container">
{{#if showNav}}
<NavHeader data-test-header-with-nav @class="{{if consoleOpen 'panel-open'}} {{if consoleFullscreen ' panel-fullscreen'}}" as |Nav|>
<Nav.home>
<HomeLink @class="navbar-item has-text-white has-current-color-fill">
{{partial 'svg/vault-logo'}}
</HomeLink>
</Nav.home>
<Nav.items>
<div class="navbar-item">
<button type="button" class="button is-transparent" {{action 'toggleConsole'}} data-test-console-toggle>
{{#if consoleOpen}}
{{i-con glyph="console-active" size=24}}
{{i-con glyph="chevron-up" aria-hidden="true" size=8 class="has-text-white auto-width is-status-chevron"}}
{{else}}
{{i-con glyph="console" size=24}}
{{i-con glyph="chevron-down" aria-hidden="true" size=8 class="has-text-white auto-width is-status-chevron"}}
{{/if}}
</button>
</div>
<div class="navbar-item">
{{status-menu}}
</div>
<div class="navbar-item">
{{status-menu type="user"}}
</div>
</Nav.items>
<Nav.main>
<ul class="navbar-sections tabs tabs-subnav">
<li class="{{if (is-active-route 'vault.cluster.secrets') 'is-active'}}">
<a href="{{href-to "vault.cluster.secrets" activeClusterName}}">
Secrets
</a>
</li>
<li class="{{if (is-active-route 'vault.cluster.access') 'is-active'}}">
<a href="{{href-to "vault.cluster.access" activeClusterName}}">
Access
</a>
</li>
<li class="{{if (is-active-route 'vault.cluster.replication') 'is-active'}}">
{{#if activeCluster.anyReplicationEnabled}}
{{status-menu type="replication"}}
{{else}}
<a href="{{href-to "vault.cluster.replication" activeClusterName}}">
Replication
{{#if (is-version "OSS")}}
<ICon @glyph="edition-enterprise" @size=16 @class="edition-icon" />
{{/if}}
</a>
{{/if}}
</li>
<li class="{{if (is-active-route (array 'vault.cluster.policies' 'vault.cluster.policy')) 'is-active'}}">
<a href="{{href-to "vault.cluster.policies" "acl" current-when='vault.cluster.policies vault.cluster.policy'}}">
Policies
</a>
</li>
<li class="{{if (is-active-route 'vault.cluster.tools') 'is-active'}}">
<a href="{{href-to "vault.cluster.tools.tool" activeClusterName "wrap" }}">
Tools
</a>
</li>
<li class="{{if (is-active-route 'vault.cluster.settings') 'is-active'}}">
<a href="{{href-to "vault.cluster.settings" activeClusterName}}">
Settings
</a>
</li>
</ul>
{{console/ui-panel isFullscreen=consoleFullscreen}}
</Nav.main>
</NavHeader>
{{/if}}
<div class="global-flash">
{{#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}}
<h5 class="title is-5 has-text-{{if (eq flash.type 'warning') 'dark-yellow' flash.type}}">
{{get (message-types flash.type) "text"}}
</h5>
<span data-test-flash-message-body=true>
{{flash.message}}
</span>
<button type="button" class="delete" {{action close}}>
{{i-con excludeIconClass=true glyph="close" aria-label="Close"}}
</button>
{{/if}}
{{/flash-message}}
{{/each}}
</div>
{{#if showNav}}
<section class="section">
<div class="container is-widescreen">
{{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}}
</div>
</section>
{{else}}
{{outlet}}
{{/if}}
{{outlet}}
<footer class="footer has-text-grey">
<div class="level">
<div class="level-item is-size-7 has-text-centered">
@ -135,7 +20,7 @@
</span>
{{/if}}
<span>
<a class="has-text-grey" target="_blank" href="https://www.vaultproject.io/docs/index.html">Documentation</a>
<a class="has-text-grey" target="_blank" rel="noreferrer noopener" href="https://www.vaultproject.io/docs/index.html">Documentation</a>
</span>
</div>
</div>

View File

@ -1,5 +1,6 @@
<form {{action (perform saveModel) on="submit"}}>
<div class="box is-sideless is-fullwidth is-marginless">
<NamespaceReminder @mode="save" @noun="auth method" />
{{message-error model=model}}
{{#if model.attrs}}
{{#each model.attrs as |attr|}}

View File

@ -1,5 +1,6 @@
<form {{action (perform saveModel) on="submit"}}>
<div class="box is-sideless is-fullwidth is-marginless">
<NamespaceReminder @mode="save" @noun="auth method" />
{{message-error model=model}}
{{#each model.tuneAttrs as |attr|}}
{{form-field data-test-field attr=attr model=model}}

View File

@ -1,94 +1,101 @@
<nav class="tabs sub-nav is-marginless">
<ul>
{{#each methodsToShow as |method|}}
{{#with (or method.path method.type) as |methodKey|}}
{{#if hasMethodsWithPath}}
<li class="{{if (and selectedAuthIsPath (eq (or selectedAuthBackend.path selectedAuthBackend.type) methodKey)) 'is-active' ''}}" data-test-auth-method>
<a href="{{href-to 'vault.cluster.auth' cluster.name (query-params with=methodKey)}}" data-test-auth-method-link={{method.type}}>
{{or method.id (capitalize method.type)}}
</a>
</li>
{{else}}
<li class="{{if (eq (or selectedAuthBackend.path selectedAuthBackend.type) methodKey) 'is-active' ''}}" data-test-auth-method>
<a href="{{href-to 'vault.cluster.auth' cluster.name (query-params with=methodKey)}}" data-test-auth-method-link={{method.type}}>
{{or method.id method.typeDisplay}}
</a>
</li>
{{/if}}
{{/with}}
{{/each}}
{{#if hasMethodsWithPath}}
<li class="{{if (not selectedAuthIsPath) 'is-active' ''}}" data-test-auth-method>
<a href="{{href-to 'vault.cluster.auth' cluster.name (query-params with='token')}}" data-test-auth-method-link=other>
Other
</a>
</li>
{{/if}}
</ul>
</nav>
<form
id="auth-form"
{{action (action "doSubmit") on="submit"}}
>
<div class="box is-marginless is-shadowless">
{{#if (and cluster.standby hasCSPError)}}
{{message-error errorMessage=cspErrorText data-test-auth-error=true}}
{{else}}
{{message-error errorMessage=error data-test-auth-error=true}}
{{/if}}
{{#if (and hasMethodsWithPath (not selectedAuthIsPath))}}
<div class="field">
<label for="selectedMethod" class="is-label">
Method
</label>
<div class="control is-expanded" >
<div class="select is-fullwidth">
<select
name="selectedMethod"
id="selectedMethod"
onchange={{action (mut selectedAuth) value="target.value"}}
data-test-method-select
>
{{#each (supported-auth-backends) as |method|}}
<option selected={{eq selectedAuthBackend.type method.type}} value={{method.type}}>
{{method.typeDisplay}}
</option>
{{/each}}
</select>
<div class="auth-form">
{{#if showLoading}}
<div class="vault-loader">
{{partial 'svg/vault-loading'}}
</div>
{{/if}}
<nav class="tabs sub-nav is-marginless">
<ul>
{{#each methodsToShow as |method|}}
{{#with (or method.path method.type) as |methodKey|}}
{{#if hasMethodsWithPath}}
<li class="{{if (and selectedAuthIsPath (eq (or selectedAuthBackend.path selectedAuthBackend.type) methodKey)) 'is-active' ''}}" data-test-auth-method>
{{#link-to 'vault.cluster.auth' cluster.name (query-params with=methodKey) data-test-auth-method-link=method.type}}
{{or method.id (capitalize method.type)}}
{{/link-to}}
</li>
{{else}}
<li class="{{if (eq (or selectedAuthBackend.path selectedAuthBackend.type) methodKey) 'is-active' ''}}" data-test-auth-method>
{{#link-to 'vault.cluster.auth' cluster.name (query-params with=methodKey) data-test-auth-method-link=method.type}}
{{or method.id method.typeDisplay}}
{{/link-to}}
</li>
{{/if}}
{{/with}}
{{/each}}
{{#if hasMethodsWithPath}}
<li class="{{if (not selectedAuthIsPath) 'is-active' ''}}" data-test-auth-method>
{{#link-to 'vault.cluster.auth' cluster.name (query-params with='token') data-test-auth-method-link="other"}}
Other
{{/link-to}}
</li>
{{/if}}
</ul>
</nav>
<form
id="auth-form"
{{action (action "doSubmit") on="submit"}}
>
<div class="box is-marginless is-shadowless">
{{#if (and cluster.standby hasCSPError)}}
{{message-error errorMessage=cspErrorText data-test-auth-error=true}}
{{else}}
{{message-error errorMessage=error data-test-auth-error=true}}
{{/if}}
{{#if (and hasMethodsWithPath (not selectedAuthIsPath))}}
<div class="field">
<label for="selectedMethod" class="is-label">
Method
</label>
<div class="control is-expanded" >
<div class="select is-fullwidth">
<select
name="selectedMethod"
id="selectedMethod"
onchange={{action (mut selectedAuth) value="target.value"}}
data-test-method-select
>
{{#each (supported-auth-backends) as |method|}}
<option selected={{eq selectedAuthBackend.type method.type}} value={{method.type}}>
{{capitalize method.type}}
</option>
{{/each}}
</select>
</div>
</div>
</div>
</div>
{{/if}}
{{partial providerPartialName}}
{{#unless (or selectedAuthIsPath (eq selectedAuthBackend.type "token"))}}
<div class="box has-slim-padding is-shadowless">
{{toggle-button toggleTarget=this toggleAttr="useCustomPath"}}
<div class="field">
{{#if useCustomPath}}
<label for="custom-path" class="is-label">
Mount path
</label>
<div class="control">
<input
type="text"
name="custom-path"
id="custom-path"
class="input"
value={{customPath}}
oninput={{action (mut customPath) value="target.value"}}
/>
</div>
<p class="help has-text-grey-dark">
If this backend was mounted using a non-default path, enter it here.
</p>
{{/if}}
{{/if}}
{{partial providerPartialName}}
{{#unless (or selectedAuthIsPath (eq selectedAuthBackend.type "token"))}}
<div class="box has-slim-padding is-shadowless">
{{toggle-button toggleTarget=this toggleAttr="useCustomPath"}}
<div class="field">
{{#if useCustomPath}}
<label for="custom-path" class="is-label">
Mount path
</label>
<div class="control">
<input
type="text"
name="custom-path"
id="custom-path"
class="input"
value={{customPath}}
oninput={{action (mut customPath) value="target.value"}}
/>
</div>
<p class="help has-text-grey-dark">
If this backend was mounted using a non-default path, enter it here.
</p>
{{/if}}
</div>
</div>
</div>
{{/unless}}
</div>
<div class="box is-marginless is-shadowless">
<button data-test-auth-submit=true type="submit" disabled={{loading}} class="button is-primary {{if loading 'is-loading'}}" id="auth-submit">
Sign In
</button>
</div>
</form>
{{/unless}}
</div>
<div class="box is-marginless is-shadowless">
<button data-test-auth-submit=true type="submit" disabled={{loading}} class="button is-primary {{if loading 'is-loading'}}" id="auth-submit">
Sign In
</button>
</div>
</form>
</div>

View File

@ -47,9 +47,9 @@
{{/if}}
{{/if}}
<li class="action">
<a href="{{href-to "vault.cluster.logout" activeClusterName }}" id="logout">
{{#link-to "vault.cluster.logout" activeClusterName id="logout"}}
Sign out
</a>
{{/link-to}}
</li>
</ul>
</nav>

View File

@ -30,6 +30,7 @@
</div>
{{else}}
<form {{action "saveCA" on="submit"}} data-test-generate-root-cert="true">
<NamespaceReminder @mode="save" @noun="PKI change" />
{{#if model.uploadPemBundle}}
{{#message-in-page type="warning" data-test-warning=true}}
<em>If you have already set a certificate and key, they will be overridden with the successful saving of a new <code>PEM bundle</code>.</em>
@ -90,8 +91,9 @@
</div>
</div>
{{else}}
{{message-error model=model}}
<h2 data-test-title class="title is-3">Sign intermediate</h2>
<NamespaceReminder @mode="save" @noun="PKI change" />
{{message-error model=model}}
<form {{action "saveCA" on="submit"}} data-test-sign-intermediate-form="true">
{{partial "partials/form-field-groups-loop"}}
<div class="field is-grouped box is-fullwidth is-bottomless">
@ -109,8 +111,9 @@
</form>
{{/if}}
{{else if setSignedIntermediate}}
{{message-error model=model}}
<h2 data-test-title class="title is-3">Set signed intermediate</h2>
<NamespaceReminder @mode="save" @noun="PKI change" />
{{message-error model=model}}
<p class="has-text-grey-dark">
Submit a signed CA certificate corresponding to a generated private key.
</p>

View File

@ -1,3 +1,5 @@
<NamespaceReminder @mode="save" @noun="PKI change" />
{{#if (eq section "tidy")}}
<p class="has-text-grey-dark" data-test-text="true">
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.

View File

@ -1,20 +1,23 @@
{{#if isRunning}}
<div class="control console-spinner is-loading"></div>
{{else}}
{{i-con glyph="chevron-right" size=12 }}
{{/if}}
<input onkeyup={{action 'handleKeyUp'}} value={{value}} autocomplete="off" spellcheck="false" />
{{#tool-tip horizontalPosition="auto-right" verticalPosition=(if isFullscreen "above" "below") as |d|}}
{{#d.trigger tagName="button" type="button" class=(concat "button is-compact" (if isFullscreen " active")) click=(action "fullscreen") data-test-tool-tip-trigger=true}}
{{i-con glyph=(if isFullscreen "fullscreen-close" "fullscreen-open") aria-hidden="true" size=16}}
{{/d.trigger}}
{{#d.content class="tool-tip"}}
<div class="box">
{{#if isFullscreen}}
Minimize
{{else}}
Maximize
{{/if}}
</div>
{{/d.content}}
{{/tool-tip}}
<div class="console-ui-input" data-test-component="console/command-input">
{{#if isRunning}}
<div class="control console-spinner is-loading"></div>
{{else}}
{{i-con glyph="chevron-right" size=12 }}
{{/if}}
<input onkeyup={{action 'handleKeyUp'}} value={{value}} autocomplete="off" spellcheck="false" />
{{#tool-tip horizontalPosition="auto-right" verticalPosition=(if isFullscreen "above" "below") as |d|}}
{{#d.trigger tagName="button" type="button" class=(concat "button is-compact" (if isFullscreen " active")) click=(action "fullscreen") data-test-tool-tip-trigger=true}}
{{i-con glyph=(if isFullscreen "fullscreen-close" "fullscreen-open") aria-hidden="true" size=16}}
{{/d.trigger}}
{{#d.content class="tool-tip"}}
<div class="box">
{{#if isFullscreen}}
Minimize
{{else}}
Maximize
{{/if}}
</div>
{{/d.content}}
{{/tool-tip}}
</div>
<NamespaceReminder @class="console-reminder" @mode="execute" @noun="command" />

View File

@ -25,9 +25,9 @@
</div>
</div>
<div class="field is-grouped box is-fullwidth is-bottomless">
<a href={{href-to 'vault.cluster.access.control-groups'}} class="button" >
{{#link-to 'vault.cluster.access.control-groups' class="button"}}
<ICon @glyph="chevron-left" @size=10 /> Back
</a>
{{/link-to}}
</div>
{{else}}
<div class="control-group-success" data-test-unwrap-form>

View File

@ -13,9 +13,9 @@
<div class="control-group">
<div data-test-requestor-text>
{{#if model.requestEntity.canRead}}
<a href="{{href-to 'vault.cluster.access.identity.show' 'entities' model.requestEntity.id 'details'}}">
{{#link-to 'vault.cluster.access.identity.show' 'entities' model.requestEntity.id 'details'}}
{{requestorName}}
</a>
{{/link-to}}
{{else}}
{{requestorName}}
{{/if}}
@ -47,7 +47,7 @@
Already approved by
{{#each model.authorizations as |authorization index|}}
{{~#if authorization.canRead~}}
<a href="{{href-to 'vault.cluster.access.identity.show' 'entities' authorization.id 'details'}}">{{authorization.name}}</a>
{{#link-to 'vault.cluster.access.identity.show' 'entities' authorization.id 'details'}}{{authorization.name}}{{/link-to}}
{{~else~}}
{{authorization.name}}
{{~/if~}}{{#if (lt (inc index) model.authorizations.length)}},{{/if}}
@ -73,9 +73,9 @@
<div class="field is-grouped box is-fullwidth is-bottomless">
{{#if model.canAuthorize}}
{{#if (or model.approved currentUserHasAuthorized)}}
<a href={{href-to 'vault.cluster.access.control-groups'}} class="button" data-test-back-link >
{{#link-to 'vault.cluster.access.control-groups'class="button" data-test-back-link=true}}
<ICon @glyph="chevron-left" @size=10 /> Back
</a>
{{/link-to}}
{{else}}
<button
type="button"

View File

@ -1,6 +1,7 @@
<form {{action (perform save model) on="submit"}}>
<MessageError @model={{model}} data-test-edit-form-error />
<div class="box is-sideless is-fullwidth is-marginless">
<NamespaceReminder @mode="save" />
{{#each model.fields as |attr|}}
{{form-field data-test-field attr=attr model=model}}
{{/each}}
@ -9,9 +10,16 @@
<div class="field is-grouped">
<div class="control">
<button type="submit" data-test-edit-form-submit class="button is-primary {{if save.isRunning 'loading'}}" disabled={{save.isRunning}}>
Save
{{saveButtonText}}
</button>
</div>
{{#if cancelLinkParams}}
<div class="control">
{{#link-to params=cancelLinkParams class="button"}}
Cancel
{{/link-to}}
</div>
{{/if}}
</div>
{{#if model.canDelete}}
<ConfirmAction

View File

@ -101,10 +101,18 @@
<input
data-test-input={{attr.name}}
id={{attr.name}}
autocomplete="off"
value={{or (get model valuePath) attr.options.defaultValue}}
oninput={{action (action "setAndBroadcast" valuePath) value="target.value"}}
class="input"
class="input"
/>
{{#if attr.options.validationAttr}}
{{#if (and (get model valuePath) (not (get model attr.options.validationAttr)))}}
<p class="has-text-danger">
{{attr.options.invalidMessage}}
</p>
{{/if}}
{{/if}}
{{/if}}
</div>
{{else if (eq attr.type 'boolean')}}

View File

@ -4,21 +4,21 @@
<ul>
<li>
<span class="sep">&#x0002f;</span>
<a href={{href-to "vault.cluster.secrets.backend" backend.id}} data-test-link="role-list">
{{#link-to "vault.cluster.secrets.backend" backend.id data-test-link="role-list"}}
{{backend.id}}
</a>
{{/link-to}}
</li>
<li class="is-active">
<span class="sep">&#x0002f;</span>
<a href={{href-to "vault.cluster.secrets.backend" backend.id}}>
{{#link-to "vault.cluster.secrets.backend" backend.id}}
creds
</a>
{{/link-to}}
</li>
<li>
<span class="sep">&#x0002f;</span>
<a href={{href-to "vault.cluster.secrets.backend.show" model.role.name}}>
{{#link-to "vault.cluster.secrets.backend.show" model.role.name}}
{{model.role.name}}
</a>
{{/link-to}}
</li>
</ul>
</nav>
@ -98,6 +98,7 @@
{{else}}
<form {{action "create" on="submit"}} data-test-secret-generate-form="true">
<div class="box is-sideless is-fullwidth is-marginless">
<NamespaceReminder @mode="generate" @noun="credential" />
{{message-error model=model}}
{{#if model.fieldGroups}}
{{partial "partials/form-field-groups-loop"}}

View File

@ -0,0 +1,7 @@
{{#link-to 'vault.cluster' 'vault' class=class}}
{{#if hasBlock}}
{{yield}}
{{else}}
{{text}}
{{/if}}
{{/link-to}}

View File

@ -1,6 +1,7 @@
<form {{action (perform save) on="submit"}}>
{{message-error model=model}}
<div class="box is-sideless is-fullwidth is-marginless">
<NamespaceReminder @mode={{mode}} @noun={{lowercase (humanize model.identityType)}} />
{{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.
@ -22,13 +23,13 @@
{{/if}}
</button>
{{#if (or (eq mode "merge") (eq mode "create" ))}}
<a href={{href-to cancelLink}} class="button" data-test-cancel-link>
{{#link-to cancelLink class="button" data-test-cancel-link=true}}
Cancel
</a>
{{/link-to}}
{{else}}
<a href={{href-to cancelLink model.id "details"}} class="button" data-test-cancel-link>
{{#link-to cancelLink model.id "details" class="button" data-test-cancel-link=true}}
Cancel
</a>
{{/link-to}}
{{/if}}
</div>
</div>

View File

@ -6,29 +6,29 @@
</p.levelLeft>
<p.levelRight>
{{#if (eq identityType "entity")}}
<a href="{{href-to 'vault.cluster.access.identity.merge' (pluralize identityType)}}" class="button has-icon-right is-ghost is-compact" data-test-entity-merge-link=true>
{{#link-to "vault.cluster.access.identity.merge" (pluralize identityType) class="button has-icon-right is-ghost is-compact" data-test-entity-merge-link=true}}
Merge {{pluralize identityType}}
{{i-con glyph="chevron-right" size=11}}
</a>
{{/link-to}}
{{/if}}
<a href="{{href-to 'vault.cluster.access.identity.create' (pluralize identityType)}}" class="button has-icon-right is-ghost is-compact" data-test-entity-create-link=true>
{{#link-to "vault.cluster.access.identity.create" (pluralize identityType) class="button has-icon-right is-ghost is-compact" data-test-entity-create-link=true}}
Create {{identityType}}
{{i-con glyph="chevron-right" size=11}}
</a>
{{/link-to}}
</p.levelRight>
</PageHeader>
<div class="box is-sideless is-fullwidth is-paddingless is-marginless">
<nav class="tabs sub-nav">
<ul>
{{#link-to "vault.cluster.access.identity.index" tagName="li"}}
<a href={{href-to "vault.cluster.access.identity.index" (pluralize identityType)}}>
{{#link-to "vault.cluster.access.identity.index" (pluralize identityType) tagName="li"}}
{{#link-to "vault.cluster.access.identity.index" (pluralize identityType)}}
{{capitalize (pluralize identityType)}}
</a>
{{/link-to}}
{{/link-to}}
{{#link-to "vault.cluster.access.identity.aliases.index" tagName="li"}}
<a href={{href-to "vault.cluster.access.identity.aliases.index" (pluralize identityType)}}>
{{#link-to "vault.cluster.access.identity.aliases.index" (pluralize identityType) tagName="li"}}
{{#link-to "vault.cluster.access.identity.aliases.index" (pluralize identityType)}}
Aliases
</a>
{{/link-to}}
{{/link-to}}
</ul>
</nav>

View File

@ -1,11 +1,11 @@
{{info-table-row label="Name" value=model.name data-test-alias-name=true}}
{{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}}
<a href={{href-to 'vault.cluster.access.identity.show' (if (eq model.identityType "entity-alias") "entities" "groups") model.canonicalId "details"}}
{{#link-to "vault.cluster.access.identity.show" (if (eq model.identityType "entity-alias") "entities" "groups") model.canonicalId "details"
class="has-text-black is-font-mono"
>
}}
{{model.canonicalId}}
</a>
{{/link-to}}
{{/info-table-row}}
{{info-table-row label="Merged from Entity ID" value=model.mergedFromCanonicalIds}}
{{#info-table-row label="Mount" value=model.mountAccessor }}

View File

@ -7,13 +7,13 @@
}}
<div class="columns is-mobile">
<div class="column is-10">
<a href={{href-to "vault.cluster.access.identity.aliases.show" item.id "details"}}
{{#link-to "vault.cluster.access.identity.aliases.show" item.id "details"
class="has-text-black has-text-weight-semibold"
>{{i-con
}}{{i-con
glyph='role'
size=14
class="has-text-grey-light"
}}<span class="has-text-weight-semibold">{{item.name}}</span></a>
}}<span class="has-text-weight-semibold">{{item.name}}</span>{{/link-to}}
<div class="has-text-grey">
{{item.id}}
</div>

View File

@ -1,26 +1,26 @@
{{#if model.groupIds}}
{{#each model.directGroupIds as |gid|}}
<a href={{href-to "vault.cluster.access.identity.show" "groups" gid "details" }}
{{#link-to "vault.cluster.access.identity.show" "groups" gid "details"
class="list-item-row"
>{{i-con
}}{{i-con
glyph='folder'
size=14
class="has-text-grey-light"
}}{{gid}}</a>
}}{{gid}}{{/link-to}}
{{/each}}
{{#each model.inheritedGroupIds as |gid|}}
{{#linked-block
"vault.cluster.access.identity.show" "groups" gid "details"
class="list-item-row"
}}
<a href={{href-to "vault.cluster.access.identity.show" "groups" gid "details" }}
{{#link-to "vault.cluster.access.identity.show" "groups" gid "details"
class="has-text-black"
>{{i-con
}}{{i-con
glyph='folder'
size=14
class="has-text-grey-light"
}}{{gid}}
</a>
{{/link-to}}
<span class="tag has-text-grey is-size-8">inherited</span>
{{/linked-block}}
{{/each}}

View File

@ -9,13 +9,13 @@
}}
<div class="columns is-mobile">
<div class="column is-10">
<a href={{href-to "vault.cluster.access.identity.show" "groups" gid "details" }}
{{#link-to "vault.cluster.access.identity.show" "groups" gid "details"
class="is-block has-text-black has-text-weight-semibold"
>{{i-con
}}{{i-con
glyph='folder'
size=14
class="has-text-grey-light"
}}{{gid}}</a>
}}{{gid}}{{/link-to}}
</div>
<div class="column has-text-right">
{{#if model.canEdit}}
@ -35,13 +35,13 @@
}}
<div class="columns">
<div class="column is-10">
<a href={{href-to "vault.cluster.access.identity.show" "entities" gid "details" }}
{{#link-to "vault.cluster.access.identity.show" "entities" gid "details"
class="is-block has-text-black has-text-weight-semibold"
>{{i-con
}}{{i-con
glyph='role'
size=14
class="has-text-grey-light"
}}{{gid}}</a>
}}{{gid}}{{/link-to}}
</div>
<div class="column has-text-right">
{{#if model.canEdit}}

View File

@ -9,13 +9,13 @@
}}
<div class="columns is-mobile">
<div class="column is-10">
<a href={{href-to "vault.cluster.access.identity.show" "groups" gid "details" }}
{{#link-to "vault.cluster.access.identity.show" "groups" gid "details"
class="is-block has-text-black has-text-weight-semibold"
>{{i-con
}}{{i-con
glyph='folder'
size=14
class="has-text-grey-light"
}}{{gid}}</a>
}}{{gid}}{{/link-to}}
</div>
<div class="column has-text-right">
</div>

View File

@ -7,10 +7,10 @@
}}
<div class="columns is-mobile">
<div class="column is-10">
<a href={{href-to "vault.cluster.policy.show" "acl" policyName}}
{{#link-to "vault.cluster.policy.show" "acl" policyName
class="is-block has-text-black has-text-weight-semibold"
><span class="is-underline">{{policyName}}</span>
</a>
}}<span class="is-underline">{{policyName}}</span>
{{/link-to}}
</div>
<div class="column has-text-right">
{{#if model.canEdit}}

View File

@ -3,9 +3,9 @@
<nav class="menu">
<ul class="menu-list">
<li class="action">
<a href={{href-to "vault.cluster.access.identity.aliases.show" (pluralize item.parentType) item.id "details" }}>
{{#link-to "vault.cluster.access.identity.aliases.show" (pluralize item.parentType) item.id "details"}}
Details
</a>
{{/link-to}}
</li>
{{#if item.updatePath.isPending}}
<li class="action">
@ -16,9 +16,9 @@
{{else}}
{{#if item.canEdit}}
<li class="action">
<a href={{href-to "vault.cluster.access.identity.aliases.edit" (pluralize item.parentType) item.id}}>
{{#link-to "vault.cluster.access.identity.aliases.edit" (pluralize item.parentType) item.id}}
Edit
</a>
{{/link-to}}
</li>
{{/if}}
{{#if item.canDelete}}

View File

@ -2,14 +2,14 @@
<nav class="menu">
<ul class="menu-list">
<li class="action">
<a href={{href-to "vault.cluster.policy.show" "acl" policyName }}>
{{#link-to "vault.cluster.policy.show" "acl" policyName}}
View Policy
</a>
{{/link-to}}
</li>
<li class="action">
<a href={{href-to "vault.cluster.policy.edit" "acl" policyName }}>
{{#link-to "vault.cluster.policy.edit" "acl" policyName}}
Edit Policy
</a>
{{/link-to}}
</li>
<li class="action">
{{#confirm-action

View File

@ -4,9 +4,9 @@
<li class="{{if (is-active-route path.path path.model isExact=true) 'is-active'}}">
<span class="sep">&#x0002f;</span>
{{#if linkToPaths}}
<a href={{href-to params=(array path.path path.model)}} data-test-secret-root-link={{if (eq index 0) true false}}>
{{#link-to params=(array path.path path.model) data-test-secret-root-link=(if (eq index 0) true false)}}
{{path.text}}
</a>
{{/link-to}}
{{else}}
<span>{{path.text}}</span>
{{/if}}

View File

@ -0,0 +1,38 @@
{{#if componentName}}
{{component componentName item=item}}
{{else if linkParams}}
<LinkedBlock @params={{linkParams}} @class="list-item-row">
<div class="level is-mobile">
<div class="level-left">
<div>
{{#link-to params=linkParams class="has-text-weight-semibold"}}
{{yield (hash content=(component "list-item/content"))}}
{{/link-to}}
</div>
</div>
<div class="level-right">
<div class="level-item">
{{#if hasBlock}}
{{yield (hash callMethod=callMethod menu=(component "list-item/popup-menu"))}}
{{/if}}
</div>
</div>
</div>
</LinkedBlock>
{{else}}
<div class="list-item-row">
<div class="level is-mobile">
<div class="level-left">
<div class="has-text-grey has-text-weight-semibold">
{{yield (hash content=(component "list-item/content"))}}
</div>
</div>
<div class="level-right">
<div class="level-item">
{{yield (hash callMethod=callMethod menu=(component "list-item/popup-menu"))}}
</div>
</div>
</div>
</div>
{{/if}}

View File

@ -0,0 +1 @@
{{yield}}

View File

@ -0,0 +1,7 @@
<PopupMenu>
<nav class="menu">
<ul class="menu-list">
{{yield item}}
</ul>
</nav>
</PopupMenu>

View File

@ -0,0 +1,19 @@
{{#if items.length}}
<div class="box is-fullwidth is-bottomless is-sideless is-paddingless">
{{#each items as |item|}}
{{yield (hash deleteItem=deleteItem saveItem=saveItem item=item)}}
{{/each}}
</div>
{{else}}
<div class="box is-bottomless has-background-white-bis">
<div class="columns is-centered">
<div class="column is-half has-text-centered">
<div class="box is-shadowless has-background-white-bis">
<p class="has-text-grey">
{{emptyMessage}}
</p>
</div>
</div>
</div>
</div>
{{/if}}

View File

@ -11,6 +11,7 @@
</PageHeader>
<form {{action (perform mountBackend) on="submit"}}>
<div class="box is-sideless is-fullwidth is-marginless">
<NamespaceReminder @mode="enable" @noun={{if (eq mountType "auth") "auth method" "secret engine"}} />
{{message-error model=mountModel}}
{{form-field-groups model=mountModel onChange=(action "onTypeChange") renderGroup="default"}}
{{#if mountModel.authConfigs.firstObject}}

View File

@ -0,0 +1,14 @@
{{#link-to "vault.cluster.secrets" (query-params namespace=normalizedNamespace)
class=(concat "is-block " class)
}}
{{#if hasBlock}}
{{yield}}
{{else}}
<div class="level is-mobile">
<span class="level-left">{{namespaceDisplay}}</span>
<button type="button" class="button is-ghost icon level-right">
<ICon @glyph="chevron-right" @size="12" @class="has-text-grey" />
</button>
</div>
{{/if}}
{{/link-to}}

View File

@ -0,0 +1,70 @@
{{#if (and (not accessibleNamespaces.length) inRootNamespace)}}
<div class="namespace-picker no-namespaces">
{{!-- Just yield the logo if they're in the root namespace and only have access to it --}}
{{yield}}
</div>
{{else}}
<div class="namespace-picker">
<BasicDropdown @horizontalPosition="auto-left" @verticalPosition="below" as |D|>
<D.trigger
@tagName="button"
@class="button is-transparent namespace-picker-trigger has-current-color"
>
{{yield}} {{#if namespaceDisplay}}<span class="namespace-name">{{namespaceDisplay}}</span>{{/if}}
<ICon
@glyph="chevron-down"
@size=8
@class="has-text-white auto-width is-status-chevron"
aria-hidden="true"
/>
</D.trigger>
<D.content @class="namespace-picker-content">
<div class="namespace-header-bar level is-mobile">
<div class="level-left">
{{#if (not isUserRootNamespace)}}
<NamespaceLink @targetNamespace={{or (object-at (dec 2 menuLeaves.length) lastMenuLeaves) auth.authData.userRootNamespace}} @class="namespace-link button is-ghost icon">
<ICon
@glyph="chevron-left"
@size=12
@class="has-text-info"
aria-hidden="true"
/>
</NamespaceLink>
{{/if}}
</div>
<div class="level-right">
{{#link-to "vault.cluster.access.namespaces" class="namespace-manage-link"}}
Manage
{{/link-to}}
</div>
</div>
<header class="current-namespace">
<h5 class="namespace-header">Current namespace</h5>
<div class="level is-mobile namespace-link">
<span class="level-left">{{or namespacePath "root"}}</span>
<ICon @glyph="checkmark-circled-outline" @size="16" @class="has-text-success level-right" />
</div>
</header>
<div class="namespace-list {{if isAnimating "animated-list"}}">
{{#if (contains '' lastMenuLeaves)}}
{{!-- leaf is '' which is the root namespace, and then we need to iterate the root leaves --}}
<div class="leaf-panel
{{if (eq '' currentLeaf) "leaf-panel-current" "leaf-panel-left"}}
">{{~#each rootLeaves as |rootLeaf|}}
<NamespaceLink @targetNamespace={{rootLeaf}} @class="namespace-link" @showLastSegment={{true}} />
{{/each~}}</div>
{{/if}}
{{#each lastMenuLeaves as |leaf index|}}
<div class="leaf-panel
{{if (eq leaf currentLeaf) "leaf-panel-current" "leaf-panel-left"}}
{{if (and isAdding (eq leaf changedLeaf)) "leaf-panel-adding"}}
{{if (and (not isAdding) (eq leaf changedLeaf)) "leaf-panel-exiting"}}
">{{~#each-in (get namespaceTree leaf) as |leafName|}}
<NamespaceLink @targetNamespace={{concat leaf "/" leafName}} @class="namespace-link" @showLastSegment={{true}} />
{{/each-in~}}</div>
{{/each}}
</div>
</D.content>
</BasicDropdown>
</div>
{{/if}}

View File

@ -0,0 +1,5 @@
{{#if showMessage}}
<p class="namespace-reminder">
This {{noun}} will be {{modeVerb}} in the <span class="tag">{{namespace.path}}</span>namespace.
</p>
{{/if}}

View File

@ -2,9 +2,9 @@
<nav class="menu">
<ul class="menu-list">
<li class="action">
<a href={{href-to 'vault.cluster.secrets.backend.show' item.idForNav}} data-test-pki-cert-link="show">
{{#link-to "vault.cluster.secrets.backend.show" item.idForNav data-test-pki-cert-link="show"}}
Details
</a>
{{/link-to}}
</li>
{{#if item.canRevoke}}
<li class="action">

View File

@ -0,0 +1,13 @@
{{#link-to params=linkParams
class=class
data-test-secret-create=data-test-secret-create
data-test-credentials-link=data-test-credentials-link
data-test-backend-credentials=data-test-backend-credentials
data-test-edit-link=data-test-edit-link
data-test-sign-link=data-test-sign-link
data-test-transit-link=data-test-transit-link
data-test-transit-key-actions-link=data-test-transit-key-actions-link
data-test-transit-action-link=data-test-transit-action-link
}}
{{yield}}
{{/link-to}}

View File

@ -1,4 +1,4 @@
{{#with (options-for-backend model.type) as |options|}}
{{#with (options-for-backend model.engineType) as |options|}}
<PageHeader as |p|>
<p.top>
{{#key-value-header
@ -8,9 +8,9 @@
}}
<li>
<span class="sep">&#x0002f;</span>
<a href={{href-to "vault.cluster.secrets"}}>
{{#link-to "vault.cluster.secrets"}}
secrets
</a>
{{/link-to}}
</li>
{{/key-value-header}}
</p.top>
@ -44,15 +44,12 @@
{{/unless}}
{{#if (or (eq model.type "aws") (eq model.type "ssh") (eq model.type "pki"))}}
<div class="control">
<a href={{href-to
"vault.cluster.settings.configure-secret-backend"
model.id
}}
{{#link-to "vault.cluster.settings.configure-secret-backend" model.id
class="button has-icon-right is-ghost is-compact"
data-test-secret-backend-configure=true
>
Configure {{i-con glyph="chevron-right" size=11}}
</a>
}}
Configure {{i-con glyph="chevron-right" size=11}}
{{/link-to}}
</div>
{{/if}}
</p.levelRight>
@ -89,7 +86,7 @@
<div class="box is-bottomless is-marginless is-fullwidth is-paddingless">
<nav class="tabs sub-nav">
<ul>
{{#if (contains model.type (supported-secret-backends))}}
{{#if (contains model.engineType (supported-secret-backends))}}
{{#link-to 'vault.cluster.secrets.backend.list-root' tagName="li" activeClass="is-active" current-when="vault.cluster.secrets.backend.list-root vault.cluster.secrets.backend.list"}}
{{#link-to 'vault.cluster.secrets.backend.list-root'}}
{{capitalize (pluralize options.item)}}

View File

@ -5,9 +5,9 @@
<ul>
{{#each tabs as |tab|}}
{{#link-to params=tab.routeParams tagName="li" data-test-auth-section-tab=true}}
<a href={{href-to params=tab.routeParams}}>
{{#link-to params=tab.routeParams}}
{{tab.label}}
</a>
{{/link-to}}
{{/link-to}}
{{/each}}
</ul>

View File

@ -1,6 +1,6 @@
<NavHeader as |Nav|>
<Nav.home>
<HomeLink @class="navbar-item splash-page-logo">
<HomeLink @class="navbar-item splash-page-logo has-text-white">
{{partial "svg/vault-edition-logo"}}
</HomeLink>
</Nav.home>
@ -18,6 +18,9 @@
<div class="splash-page-header">
{{yield (hash header=(component 'splash-page/splash-header'))}}
</div>
<div class="splash-page-sub-header">
{{yield (hash sub-header=(component 'splash-page/splash-header'))}}
</div>
<div class="login-form box is-paddingless is-relative">
{{yield (hash content=(component 'splash-page/splash-content'))}}
</div>

View File

@ -1,4 +1,3 @@
{{message-error errors=errors}}
<form onsubmit={{action "doSubmit"}}>
{{partial (concat "partials/tools/" selectedAction)}}
</form>

View File

@ -47,6 +47,7 @@
</div>
{{else}}
<div class="box is-sideless is-fullwidth is-marginless">
<NamespaceReminder @mode="perform" @noun="datakey creation" />
<div class="field">
<label for="param" class="is-label">Output format</label>
<div class="control is-expanded">

View File

@ -27,6 +27,7 @@
</div>
{{else}}
<div class="box is-sideless is-fullwidth is-marginless">
<NamespaceReminder @mode="perform" @noun="encryption" />
{{key-version-select
key=key
onVersionChange=(action (mut key_version))

View File

@ -27,6 +27,7 @@
</div>
{{else}}
<div class="box is-sideless is-fullwidth is-marginless">
<NamespaceReminder @mode="perform" @noun="HMAC creation" />
{{key-version-select
key=key
onVersionChange=(action (mut key_version))

View File

@ -1,5 +1,6 @@
<form {{action 'doSubmit' (hash ciphertext=ciphertext context=context nonce=nonce key_version=key_version) on="submit"}}>
<div class="box is-sideless is-fullwidth is-marginless">
<NamespaceReminder @mode="perform" @noun="rewrap" />
{{key-version-select
key=key
onVersionChange=(action (mut key_version))

View File

@ -27,6 +27,7 @@
</div>
{{else}}
<div class="box is-sideless is-fullwidth is-marginless">
<NamespaceReminder @mode="perform" @noun="signing" />
{{key-version-select
key=key
onVersionChange=(action (mut key_version))

View File

@ -8,6 +8,7 @@
id="token"
class="input"
data-test-token=true
autocomplete="off"
}}
</div>
</div>

View File

@ -6,6 +6,7 @@
value=token
name="token"
class="input"
autocomplete="off"
data-test-token=true
}}
</div>

View File

@ -42,9 +42,9 @@
</div>
<div class="level-right">
{{#if replicationDisabled}}
<a href="{{href-to 'vault.cluster.replication.mode.index' cluster.name mode}}" class="button is-primary">
{{#link-to "vault.cluster.replication.mode.index" cluster.name mode class="button is-primary"}}
Enable
</a>
{{/link-to}}
{{else if (eq mode 'dr')}}
{{cluster.drReplicationStateDisplay}}
{{else if (eq mode 'performance')}}

Some files were not shown because too many files have changed in this diff Show More